diff --git a/.changeset/metal-hornets-drum.md b/.changeset/metal-hornets-drum.md new file mode 100644 index 0000000000..04a7302f8a --- /dev/null +++ b/.changeset/metal-hornets-drum.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-pinniped-provider': patch +--- + +Introduced metadata cache for the `pinniped` provider. diff --git a/plugins/auth-backend-module-pinniped-provider/api-report.md b/plugins/auth-backend-module-pinniped-provider/api-report.md index b9b993bd1e..430099713e 100644 --- a/plugins/auth-backend-module-pinniped-provider/api-report.md +++ b/plugins/auth-backend-module-pinniped-provider/api-report.md @@ -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 ->; + }>; +} ``` diff --git a/plugins/auth-backend-module-pinniped-provider/package.json b/plugins/auth-backend-module-pinniped-provider/package.json index 087a90cdbb..5541d008da 100644 --- a/plugins/auth-backend-module-pinniped-provider/package.json +++ b/plugins/auth-backend-module-pinniped-provider/package.json @@ -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", diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts index f908c1ea13..2ecd54e80a 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts @@ -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; 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); + }); }); }); diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts index 83c4204ad0..e07eaba759 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts @@ -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) => { diff --git a/plugins/auth-backend-module-pinniped-provider/src/index.ts b/plugins/auth-backend-module-pinniped-provider/src/index.ts index 9a2ca6727e..df4cd58603 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/index.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/index.ts @@ -20,5 +20,5 @@ * @packageDocumentation */ -export { pinnipedAuthenticator } from './authenticator'; +export { pinnipedAuthenticator, PinnipedStrategyCache } from './authenticator'; export { authModulePinnipedProvider } from './module'; diff --git a/yarn.lock b/yarn.lock index cad6b0ee67..2211706957 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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