Merge pull request #20909 from RubenV-dev/pinniped-cache
Add metadata cache to PinnipedAuthenticator
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-pinniped-provider': patch
|
||||
---
|
||||
|
||||
Introduced metadata cache for the `pinniped` provider.
|
||||
@@ -5,6 +5,7 @@
|
||||
```ts
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { BaseClient } from 'openid-client';
|
||||
import { Config } from '@backstage/config';
|
||||
import { OAuthAuthenticator } from '@backstage/plugin-auth-node';
|
||||
import { Strategy } from 'openid-client';
|
||||
import { TokenSet } from 'openid-client';
|
||||
@@ -14,7 +15,15 @@ export const authModulePinnipedProvider: () => BackendFeature;
|
||||
|
||||
// @public (undocumented)
|
||||
export const pinnipedAuthenticator: OAuthAuthenticator<
|
||||
Promise<{
|
||||
PinnipedStrategyCache,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// @public (undocumented)
|
||||
export class PinnipedStrategyCache {
|
||||
constructor(callbackUrl: string, config: Config);
|
||||
// (undocumented)
|
||||
getStrategy(): Promise<{
|
||||
providerStrategy: Strategy<
|
||||
{
|
||||
tokenset: TokenSet;
|
||||
@@ -22,7 +31,6 @@ export const pinnipedAuthenticator: OAuthAuthenticator<
|
||||
BaseClient
|
||||
>;
|
||||
client: BaseClient;
|
||||
}>,
|
||||
unknown
|
||||
>;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -25,14 +25,15 @@
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"luxon": "^3.4.3",
|
||||
"openid-client": "^5.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-defaults": "workspace:^",
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/plugin-auth-backend": "workspace:^",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.18.2",
|
||||
|
||||
@@ -28,9 +28,10 @@ import { ConfigReader } from '@backstage/config';
|
||||
import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||
import { rest } from 'msw';
|
||||
import express from 'express';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
describe('pinnipedAuthenticator', () => {
|
||||
let implementation: any;
|
||||
let authCtx: any;
|
||||
let oauthState: OAuthState;
|
||||
let idToken: string;
|
||||
let publicKey: JWK;
|
||||
@@ -85,6 +86,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
|
||||
mswServer.use(
|
||||
rest.get(
|
||||
@@ -128,7 +130,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
implementation = pinnipedAuthenticator.initialize({
|
||||
authCtx = pinnipedAuthenticator.initialize({
|
||||
callbackUrl: 'https://backstage.test/callback',
|
||||
config: new ConfigReader({
|
||||
federationDomain: 'https://federationDomain.test',
|
||||
@@ -143,6 +145,17 @@ describe('pinnipedAuthenticator', () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe('#initialize', () => {
|
||||
it('always returns a PinnipedStrategyCache', async () => {
|
||||
const { providerStrategy, client } = await authCtx.getStrategy();
|
||||
|
||||
expect(providerStrategy).toBeDefined();
|
||||
expect(client.issuer.authorization_endpoint).toMatch(
|
||||
'https://pinniped.test/oauth2/authorize',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#start', () => {
|
||||
let fakeSession: Record<string, any>;
|
||||
let startRequest: OAuthAuthenticatorStartInput;
|
||||
@@ -162,7 +175,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => {
|
||||
const startResponse = await pinnipedAuthenticator.start(
|
||||
startRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
const url = new URL(startResponse.url);
|
||||
|
||||
@@ -174,7 +187,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
it('initiates authorization code grant', async () => {
|
||||
const startResponse = await pinnipedAuthenticator.start(
|
||||
startRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
const { searchParams } = new URL(startResponse.url);
|
||||
|
||||
@@ -185,7 +198,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
startRequest.req.query = { audience: 'test-cluster' };
|
||||
const startResponse = await pinnipedAuthenticator.start(
|
||||
startRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
const { searchParams } = new URL(startResponse.url);
|
||||
const stateParam = searchParams.get('state');
|
||||
@@ -201,7 +214,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
it('passes client ID from config', async () => {
|
||||
const startResponse = await pinnipedAuthenticator.start(
|
||||
startRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
const { searchParams } = new URL(startResponse.url);
|
||||
|
||||
@@ -211,7 +224,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
it('passes callback URL from config', async () => {
|
||||
const startResponse = await pinnipedAuthenticator.start(
|
||||
startRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
const { searchParams } = new URL(startResponse.url);
|
||||
|
||||
@@ -223,7 +236,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
it('generates PKCE challenge', async () => {
|
||||
const startResponse = await pinnipedAuthenticator.start(
|
||||
startRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
const { searchParams } = new URL(startResponse.url);
|
||||
|
||||
@@ -232,14 +245,14 @@ describe('pinnipedAuthenticator', () => {
|
||||
});
|
||||
|
||||
it('stores PKCE verifier in session', async () => {
|
||||
await pinnipedAuthenticator.start(startRequest, implementation);
|
||||
await pinnipedAuthenticator.start(startRequest, authCtx);
|
||||
expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined();
|
||||
});
|
||||
|
||||
it('requests sufficient scopes for token exchange by default', async () => {
|
||||
const startResponse = await pinnipedAuthenticator.start(
|
||||
startRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
const { searchParams } = new URL(startResponse.url);
|
||||
const scopes = searchParams.get('scope')?.split(' ') ?? [];
|
||||
@@ -257,7 +270,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
it('encodes OAuth state in query param', async () => {
|
||||
const startResponse = await pinnipedAuthenticator.start(
|
||||
startRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
const { searchParams } = new URL(startResponse.url);
|
||||
const stateParam = searchParams.get('state');
|
||||
@@ -276,10 +289,103 @@ describe('pinnipedAuthenticator', () => {
|
||||
url: 'test',
|
||||
},
|
||||
} as unknown as OAuthAuthenticatorStartInput,
|
||||
implementation,
|
||||
authCtx,
|
||||
),
|
||||
).rejects.toThrow('authentication requires session support');
|
||||
});
|
||||
|
||||
it('refreshes oidc metadata after a failed fetch', async () => {
|
||||
mswServer.use(
|
||||
rest.get(
|
||||
'https://federationDomain.test/.well-known/openid-configuration',
|
||||
(_req, res, _ctx) => res.networkError('Timeout'),
|
||||
),
|
||||
);
|
||||
|
||||
const authCtxCreatedWhileSupervisorUnavailable =
|
||||
pinnipedAuthenticator.initialize({
|
||||
callbackUrl: 'https://backstage.test/callback',
|
||||
config: new ConfigReader({
|
||||
federationDomain: 'https://federationDomain.test',
|
||||
clientId: 'clientId',
|
||||
clientSecret: 'clientSecret',
|
||||
}),
|
||||
});
|
||||
|
||||
mswServer.use(
|
||||
rest.get(
|
||||
'https://federationDomain.test/.well-known/openid-configuration',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(issuerMetadata),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = await pinnipedAuthenticator.start(
|
||||
startRequest,
|
||||
authCtxCreatedWhileSupervisorUnavailable,
|
||||
);
|
||||
|
||||
expect(response.url).toMatch('https://pinniped.test/oauth2/authorize');
|
||||
});
|
||||
|
||||
it('caches oidc metadata after a success', async () => {
|
||||
// we start with 1 because the supervisor was called once already when we initialize.
|
||||
let supervisorCalls: number = 1;
|
||||
|
||||
mswServer.use(
|
||||
rest.get(
|
||||
'https://federationDomain.test/.well-known/openid-configuration',
|
||||
(_req, res, ctx) => {
|
||||
supervisorCalls += 1;
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(issuerMetadata),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await pinnipedAuthenticator.start(startRequest, authCtx);
|
||||
await pinnipedAuthenticator.start(startRequest, authCtx);
|
||||
|
||||
expect(supervisorCalls).toEqual(1);
|
||||
});
|
||||
|
||||
it('refreshes oidc metadata when current one in cache expires', async () => {
|
||||
// we start with 1 because the supervisor was called once already when we initialize.
|
||||
let supervisorCalls: number = 1;
|
||||
const fixedTime = DateTime.local();
|
||||
jest.spyOn(DateTime, 'local').mockImplementation(() => fixedTime);
|
||||
|
||||
mswServer.use(
|
||||
rest.get(
|
||||
'https://federationDomain.test/.well-known/openid-configuration',
|
||||
(_req, res, ctx) => {
|
||||
supervisorCalls += 1;
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(issuerMetadata),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await pinnipedAuthenticator.start(startRequest, authCtx);
|
||||
|
||||
jest
|
||||
.spyOn(DateTime, 'local')
|
||||
.mockImplementation(() => fixedTime.plus({ seconds: 60000 }));
|
||||
|
||||
await pinnipedAuthenticator.start(startRequest, authCtx);
|
||||
|
||||
expect(supervisorCalls).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#authenticate', () => {
|
||||
@@ -304,7 +410,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
it('exchanges authorization code for access token', async () => {
|
||||
const handlerResponse = await pinnipedAuthenticator.authenticate(
|
||||
handlerRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
const accessToken = handlerResponse.session.accessToken;
|
||||
|
||||
@@ -314,7 +420,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
it('exchanges authorization code for refresh token', async () => {
|
||||
const handlerResponse = await pinnipedAuthenticator.authenticate(
|
||||
handlerRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
const refreshToken = handlerResponse.session.refreshToken;
|
||||
|
||||
@@ -324,7 +430,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
it('returns granted scope', async () => {
|
||||
const handlerResponse = await pinnipedAuthenticator.authenticate(
|
||||
handlerRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
const responseScope = handlerResponse.session.scope;
|
||||
|
||||
@@ -349,7 +455,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
|
||||
const handlerResponse = await pinnipedAuthenticator.authenticate(
|
||||
handlerRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
|
||||
expect(handlerResponse.session.idToken).toEqual(clusterScopedIdToken);
|
||||
@@ -413,7 +519,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
};
|
||||
|
||||
await expect(
|
||||
pinnipedAuthenticator.authenticate(handlerRequest, implementation),
|
||||
pinnipedAuthenticator.authenticate(handlerRequest, authCtx),
|
||||
).rejects.toThrow(
|
||||
`Failed to get cluster specific ID token for "test_cluster": Error: RFC8693 token exchange failed with error: NetworkError: Connection timed out`,
|
||||
);
|
||||
@@ -422,7 +528,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
it('fails without authorization code', async () => {
|
||||
handlerRequest.req.url = 'https://test.com';
|
||||
return expect(
|
||||
pinnipedAuthenticator.authenticate(handlerRequest, implementation),
|
||||
pinnipedAuthenticator.authenticate(handlerRequest, authCtx),
|
||||
).rejects.toThrow('Unexpected redirect');
|
||||
});
|
||||
|
||||
@@ -440,7 +546,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
},
|
||||
} as unknown as express.Request,
|
||||
},
|
||||
implementation,
|
||||
authCtx,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Authentication rejected, state missing from the response',
|
||||
@@ -456,10 +562,140 @@ describe('pinnipedAuthenticator', () => {
|
||||
url: 'https://test.com',
|
||||
} as unknown as express.Request,
|
||||
},
|
||||
implementation,
|
||||
authCtx,
|
||||
),
|
||||
).rejects.toThrow('authentication requires session support');
|
||||
});
|
||||
|
||||
it('refreshes oidc metadata after a failed fetch', async () => {
|
||||
mswServer.use(
|
||||
rest.get(
|
||||
'https://federationDomain.test/.well-known/openid-configuration',
|
||||
(_req, res, _ctx) => res.networkError('Timeout'),
|
||||
),
|
||||
);
|
||||
|
||||
const authCtxCreatedWhileSupervisorUnavailable =
|
||||
pinnipedAuthenticator.initialize({
|
||||
callbackUrl: 'https://backstage.test/callback',
|
||||
config: new ConfigReader({
|
||||
federationDomain: 'https://federationDomain.test',
|
||||
clientId: 'clientId',
|
||||
clientSecret: 'clientSecret',
|
||||
}),
|
||||
});
|
||||
|
||||
mswServer.use(
|
||||
rest.get(
|
||||
'https://federationDomain.test/.well-known/openid-configuration',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(issuerMetadata),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = await pinnipedAuthenticator.authenticate(
|
||||
handlerRequest,
|
||||
authCtxCreatedWhileSupervisorUnavailable,
|
||||
);
|
||||
expect(response.session.accessToken).toEqual('accessToken');
|
||||
});
|
||||
|
||||
it('caches oidc metadata after a success', async () => {
|
||||
let supervisorCalls: number = 1;
|
||||
|
||||
mswServer.use(
|
||||
rest.get(
|
||||
'https://federationDomain.test/.well-known/openid-configuration',
|
||||
(_req, res, ctx) => {
|
||||
supervisorCalls += 1;
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(issuerMetadata),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await pinnipedAuthenticator.authenticate(handlerRequest, authCtx);
|
||||
|
||||
await pinnipedAuthenticator.authenticate(
|
||||
{
|
||||
req: {
|
||||
method: 'GET',
|
||||
url: `https://test?code=authorization_code&state=${encodeOAuthState(
|
||||
oauthState,
|
||||
)}`,
|
||||
session: {
|
||||
'oidc:pinniped.test': {
|
||||
state: encodeOAuthState(oauthState),
|
||||
},
|
||||
},
|
||||
} as unknown as express.Request,
|
||||
},
|
||||
authCtx,
|
||||
);
|
||||
|
||||
expect(supervisorCalls).toEqual(1);
|
||||
});
|
||||
|
||||
it('refreshes oidc metadata when current one in cache expires', async () => {
|
||||
let supervisorCalls: number = 0;
|
||||
const fixedTime = DateTime.local();
|
||||
jest.spyOn(DateTime, 'local').mockImplementation(() => fixedTime);
|
||||
|
||||
mswServer.use(
|
||||
rest.get(
|
||||
'https://federationDomain.test/.well-known/openid-configuration',
|
||||
(_req, res, ctx) => {
|
||||
supervisorCalls += 1;
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(issuerMetadata),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
authCtx = pinnipedAuthenticator.initialize({
|
||||
callbackUrl: 'https://backstage.test/callback',
|
||||
config: new ConfigReader({
|
||||
federationDomain: 'https://federationDomain.test',
|
||||
clientId: 'clientId',
|
||||
clientSecret: 'clientSecret',
|
||||
}),
|
||||
});
|
||||
|
||||
await pinnipedAuthenticator.authenticate(handlerRequest, authCtx);
|
||||
|
||||
jest
|
||||
.spyOn(DateTime, 'local')
|
||||
.mockImplementation(() => fixedTime.plus({ seconds: 60000 }));
|
||||
|
||||
await pinnipedAuthenticator.authenticate(
|
||||
{
|
||||
req: {
|
||||
method: 'GET',
|
||||
url: `https://test?code=authorization_code&state=${encodeOAuthState(
|
||||
oauthState,
|
||||
)}`,
|
||||
session: {
|
||||
'oidc:pinniped.test': {
|
||||
state: encodeOAuthState(oauthState),
|
||||
},
|
||||
},
|
||||
} as unknown as express.Request,
|
||||
},
|
||||
authCtx,
|
||||
);
|
||||
|
||||
expect(supervisorCalls).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#refresh', () => {
|
||||
@@ -476,7 +712,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
it('gets new refresh token', async () => {
|
||||
const refreshResponse = await pinnipedAuthenticator.refresh(
|
||||
refreshRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
|
||||
expect(refreshResponse.session.refreshToken).toBe('refreshToken');
|
||||
@@ -485,7 +721,7 @@ describe('pinnipedAuthenticator', () => {
|
||||
it('gets access token', async () => {
|
||||
const refreshResponse = await pinnipedAuthenticator.refresh(
|
||||
refreshRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
|
||||
expect(refreshResponse.session.accessToken).toBe('accessToken');
|
||||
@@ -494,10 +730,100 @@ describe('pinnipedAuthenticator', () => {
|
||||
it('gets id token', async () => {
|
||||
const refreshResponse = await pinnipedAuthenticator.refresh(
|
||||
refreshRequest,
|
||||
implementation,
|
||||
authCtx,
|
||||
);
|
||||
|
||||
expect(refreshResponse.session.idToken).toBe(idToken);
|
||||
});
|
||||
|
||||
it('refreshes oidc metadata after a failed fetch', async () => {
|
||||
mswServer.use(
|
||||
rest.get(
|
||||
'https://federationDomain.test/.well-known/openid-configuration',
|
||||
(_req, res, _ctx) => res.networkError('Timeout'),
|
||||
),
|
||||
);
|
||||
|
||||
const authCtxCreatedWhileSupervisorUnavailable =
|
||||
pinnipedAuthenticator.initialize({
|
||||
callbackUrl: 'https://backstage.test/callback',
|
||||
config: new ConfigReader({
|
||||
federationDomain: 'https://federationDomain.test',
|
||||
clientId: 'clientId',
|
||||
clientSecret: 'clientSecret',
|
||||
}),
|
||||
});
|
||||
|
||||
mswServer.use(
|
||||
rest.get(
|
||||
'https://federationDomain.test/.well-known/openid-configuration',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(issuerMetadata),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = await pinnipedAuthenticator.refresh(
|
||||
refreshRequest,
|
||||
authCtxCreatedWhileSupervisorUnavailable,
|
||||
);
|
||||
expect(response.session.accessToken).toEqual('accessToken');
|
||||
});
|
||||
|
||||
it('caches oidc metadata after a success', async () => {
|
||||
let supervisorCalls: number = 1;
|
||||
|
||||
mswServer.use(
|
||||
rest.get(
|
||||
'https://federationDomain.test/.well-known/openid-configuration',
|
||||
(_req, res, ctx) => {
|
||||
supervisorCalls += 1;
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(issuerMetadata),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await pinnipedAuthenticator.refresh(refreshRequest, authCtx);
|
||||
await pinnipedAuthenticator.refresh(refreshRequest, authCtx);
|
||||
|
||||
expect(supervisorCalls).toEqual(1);
|
||||
});
|
||||
|
||||
it('refreshes oidc metadata when current one in cache expires', async () => {
|
||||
let supervisorCalls: number = 1;
|
||||
const fixedTime = DateTime.local();
|
||||
jest.spyOn(DateTime, 'local').mockImplementation(() => fixedTime);
|
||||
|
||||
mswServer.use(
|
||||
rest.get(
|
||||
'https://federationDomain.test/.well-known/openid-configuration',
|
||||
(_req, res, ctx) => {
|
||||
supervisorCalls += 1;
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(issuerMetadata),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await pinnipedAuthenticator.refresh(refreshRequest, authCtx);
|
||||
|
||||
jest
|
||||
.spyOn(DateTime, 'local')
|
||||
.mockImplementation(() => fixedTime.plus({ seconds: 60000 }));
|
||||
|
||||
await pinnipedAuthenticator.refresh(refreshRequest, authCtx);
|
||||
|
||||
expect(supervisorCalls).toEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Config } from '@backstage/config';
|
||||
import { PassportDoneCallback } from '@backstage/plugin-auth-node';
|
||||
import {
|
||||
createOAuthAuthenticator,
|
||||
@@ -24,7 +25,9 @@ import {
|
||||
Issuer,
|
||||
TokenSet,
|
||||
Strategy as OidcStrategy,
|
||||
BaseClient,
|
||||
} from 'openid-client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
const rfc8693TokenExchange = async ({
|
||||
subject_token,
|
||||
@@ -53,22 +56,78 @@ const rfc8693TokenExchange = async ({
|
||||
});
|
||||
};
|
||||
|
||||
const OIDC_METADATA_TTL_SECONDS = 3600;
|
||||
|
||||
/** @public */
|
||||
export const pinnipedAuthenticator = createOAuthAuthenticator({
|
||||
defaultProfileTransform: async (_r, _c) => ({ profile: {} }),
|
||||
async initialize({ callbackUrl, config }) {
|
||||
export class PinnipedStrategyCache {
|
||||
private readonly callbackUrl: string;
|
||||
private readonly config: Config;
|
||||
private strategyPromise: Promise<{
|
||||
providerStrategy: OidcStrategy<{ tokenset: TokenSet }, BaseClient>;
|
||||
client: BaseClient;
|
||||
}>;
|
||||
|
||||
private cachedPromise?: Promise<{
|
||||
providerStrategy: OidcStrategy<{ tokenset: TokenSet }, BaseClient>;
|
||||
client: BaseClient;
|
||||
}>;
|
||||
private cachedPromiseExpiry?: Date;
|
||||
|
||||
constructor(callbackUrl: string, config: Config) {
|
||||
this.callbackUrl = callbackUrl;
|
||||
this.config = config;
|
||||
this.strategyPromise = this.buildStrategy();
|
||||
}
|
||||
|
||||
public async getStrategy(): Promise<{
|
||||
providerStrategy: OidcStrategy<{ tokenset: TokenSet }, BaseClient>;
|
||||
client: BaseClient;
|
||||
}> {
|
||||
if (this.cachedPromise) {
|
||||
if (
|
||||
this.cachedPromiseExpiry &&
|
||||
DateTime.fromJSDate(this.cachedPromiseExpiry) > DateTime.local()
|
||||
) {
|
||||
return this.cachedPromise;
|
||||
}
|
||||
// cachedPromise has expired, remove promise from cache and regenerate strategy
|
||||
this.strategyPromise = this.buildStrategy();
|
||||
delete this.cachedPromise;
|
||||
}
|
||||
|
||||
try {
|
||||
// if strategy is generated successfully, save it to cache
|
||||
await this.strategyPromise;
|
||||
this.cachedPromise = this.strategyPromise;
|
||||
this.cachedPromiseExpiry = DateTime.utc()
|
||||
.plus({ seconds: OIDC_METADATA_TTL_SECONDS })
|
||||
.toJSDate();
|
||||
} catch (error) {
|
||||
// if we fail to generate a strategy, retry and overwrite strategy
|
||||
this.strategyPromise = this.buildStrategy();
|
||||
delete this.cachedPromise;
|
||||
delete this.cachedPromiseExpiry;
|
||||
}
|
||||
|
||||
return this.strategyPromise;
|
||||
}
|
||||
|
||||
private async buildStrategy(): Promise<{
|
||||
providerStrategy: OidcStrategy<{ tokenset: TokenSet }, BaseClient>;
|
||||
client: BaseClient;
|
||||
}> {
|
||||
const issuer = await Issuer.discover(
|
||||
`${config.getString(
|
||||
`${this.config.getString(
|
||||
'federationDomain',
|
||||
)}/.well-known/openid-configuration`,
|
||||
);
|
||||
const client = new issuer.Client({
|
||||
access_type: 'offline', // this option must be passed to provider to receive a refresh token
|
||||
client_id: config.getString('clientId'),
|
||||
client_secret: config.getString('clientSecret'),
|
||||
redirect_uris: [callbackUrl],
|
||||
access_type: 'offline',
|
||||
client_id: this.config.getString('clientId'),
|
||||
client_secret: this.config.getString('clientSecret'),
|
||||
redirect_uris: [this.callbackUrl],
|
||||
response_types: ['code'],
|
||||
scope: config.getOptionalString('scope') || '',
|
||||
scope: this.config.getOptionalString('scope') || '',
|
||||
id_token_signed_response_alg: 'ES256',
|
||||
});
|
||||
const providerStrategy = new OidcStrategy(
|
||||
@@ -88,12 +147,18 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({
|
||||
done(undefined, { tokenset }, {});
|
||||
},
|
||||
);
|
||||
|
||||
return { providerStrategy, client };
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async start(input, ctx) {
|
||||
const { providerStrategy } = await ctx;
|
||||
/** @public */
|
||||
export const pinnipedAuthenticator = createOAuthAuthenticator({
|
||||
defaultProfileTransform: async (_r, _c) => ({ profile: {} }),
|
||||
initialize({ callbackUrl, config }) {
|
||||
return new PinnipedStrategyCache(callbackUrl, config);
|
||||
},
|
||||
async start(input, ctx): Promise<{ url: string; status?: number }> {
|
||||
const { providerStrategy } = await ctx.getStrategy();
|
||||
const stringifiedAudience = input.req.query?.audience as string;
|
||||
const decodedState = decodeOAuthState(input.state);
|
||||
const state = { ...decodedState, audience: stringifiedAudience };
|
||||
@@ -117,7 +182,7 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({
|
||||
},
|
||||
|
||||
async authenticate(input, ctx) {
|
||||
const { providerStrategy } = await ctx;
|
||||
const { providerStrategy } = await ctx.getStrategy();
|
||||
const { req } = input;
|
||||
const { searchParams } = new URL(req.url, 'https://pinniped.com');
|
||||
const stateParam = searchParams.get('state');
|
||||
@@ -132,7 +197,7 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({
|
||||
? rfc8693TokenExchange({
|
||||
subject_token: user.tokenset.access_token,
|
||||
target_audience: audience,
|
||||
ctx,
|
||||
ctx: ctx.getStrategy(),
|
||||
}).catch(err =>
|
||||
reject(
|
||||
new Error(
|
||||
@@ -172,7 +237,7 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({
|
||||
},
|
||||
|
||||
async refresh(input, ctx) {
|
||||
const { client } = await ctx;
|
||||
const { client } = await ctx.getStrategy();
|
||||
const tokenset = await client.refresh(input.refreshToken);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export { pinnipedAuthenticator } from './authenticator';
|
||||
export { pinnipedAuthenticator, PinnipedStrategyCache } from './authenticator';
|
||||
export { authModulePinnipedProvider } from './module';
|
||||
|
||||
Reference in New Issue
Block a user