Merge pull request #20909 from RubenV-dev/pinniped-cache

Add metadata cache to PinnipedAuthenticator
This commit is contained in:
Patrik Oldsberg
2023-11-14 12:29:12 +01:00
committed by GitHub
7 changed files with 451 additions and 45 deletions
+5
View File
@@ -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';
+1
View File
@@ -5037,6 +5037,7 @@ __metadata:
express-promise-router: ^4.1.1
express-session: ^1.17.3
jose: ^4.14.6
luxon: ^3.4.3
msw: ^1.3.0
openid-client: ^5.4.3
passport: ^0.6.0