diff --git a/.changeset/auth0-cache-profile-refresh.md b/.changeset/auth0-cache-profile-refresh.md new file mode 100644 index 0000000000..69f8f546b2 --- /dev/null +++ b/.changeset/auth0-cache-profile-refresh.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-auth0-provider': patch +--- + +Added `createAuth0Authenticator` factory function that accepts a `CacheService` to cache Auth0 profile API responses for 1 minute during token refreshes. This avoids hitting Auth0 rate limits on repeated page refreshes. The module now uses the cached variant by default. The existing `auth0Authenticator` export remains available for use without caching. diff --git a/plugins/auth-backend-module-auth0-provider/package.json b/plugins/auth-backend-module-auth0-provider/package.json index 6c8843060a..f041c770bd 100644 --- a/plugins/auth-backend-module-auth0-provider/package.json +++ b/plugins/auth-backend-module-auth0-provider/package.json @@ -37,6 +37,7 @@ "@backstage/backend-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", + "@types/passport": "^1.0.3", "express": "^4.22.0", "passport": "^0.7.0", "passport-auth0": "^1.4.3", @@ -48,7 +49,6 @@ "@backstage/cli": "workspace:^", "@backstage/plugin-auth-backend": "workspace:^", "@backstage/types": "workspace:^", - "@types/passport": "^1.0.3", "@types/passport-auth0": "^1.0.5", "@types/passport-oauth2": "^1.4.15", "supertest": "^7.0.0" diff --git a/plugins/auth-backend-module-auth0-provider/report.api.md b/plugins/auth-backend-module-auth0-provider/report.api.md index 75c9ec2f87..479c9f7256 100644 --- a/plugins/auth-backend-module-auth0-provider/report.api.md +++ b/plugins/auth-backend-module-auth0-provider/report.api.md @@ -4,14 +4,17 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { CacheService } from '@backstage/backend-plugin-api'; import { OAuthAuthenticator } from '@backstage/plugin-auth-node'; import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node'; import { PassportProfile } from '@backstage/plugin-auth-node'; +import { Strategy } from 'passport'; // @public (undocumented) export const auth0Authenticator: OAuthAuthenticator< { helper: PassportOAuthAuthenticatorHelper; + strategy: Strategy; audience: string | undefined; connection: string | undefined; connectionScope: string | undefined; @@ -25,4 +28,21 @@ export const auth0Authenticator: OAuthAuthenticator< // @public (undocumented) const authModuleAuth0Provider: BackendFeature; export default authModuleAuth0Provider; + +// @public (undocumented) +export function createAuth0Authenticator(options?: { + cache?: CacheService; +}): OAuthAuthenticator< + { + helper: PassportOAuthAuthenticatorHelper; + strategy: Strategy; + audience: string | undefined; + connection: string | undefined; + connectionScope: string | undefined; + domain: string; + clientID: string; + federated: boolean; + }, + PassportProfile +>; ``` diff --git a/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts b/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts new file mode 100644 index 0000000000..6d783fc96f --- /dev/null +++ b/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts @@ -0,0 +1,227 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CacheService } from '@backstage/backend-plugin-api'; +import { mockServices } from '@backstage/backend-test-utils'; +import { + PassportHelpers, + PassportOAuthAuthenticatorHelper, +} from '@backstage/plugin-auth-node'; +import { createAuth0Authenticator } from './authenticator'; +import express from 'express'; + +// Mock PassportHelpers so we don't need a real OAuth2 connection +jest.mock('@backstage/plugin-auth-node', () => { + const actual = jest.requireActual('@backstage/plugin-auth-node'); + return { + ...actual, + PassportHelpers: { + ...actual.PassportHelpers, + executeRefreshTokenStrategy: jest.fn(), + }, + PassportOAuthAuthenticatorHelper: { + ...actual.PassportOAuthAuthenticatorHelper, + defaultProfileTransform: + actual.PassportOAuthAuthenticatorHelper.defaultProfileTransform, + from: jest.fn(() => ({ + start: jest.fn(), + authenticate: jest.fn(), + fetchProfile: jest.fn(), + })), + }, + }; +}); + +const mockExecuteRefresh = + PassportHelpers.executeRefreshTokenStrategy as jest.MockedFunction< + typeof PassportHelpers.executeRefreshTokenStrategy + >; + +const mockFrom = PassportOAuthAuthenticatorHelper.from as jest.MockedFunction< + typeof PassportOAuthAuthenticatorHelper.from +>; + +describe('createAuth0Authenticator', () => { + const mockConfig = mockServices.rootConfig({ + data: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + domain: 'test.auth0.com', + }, + }); + + const mockProfile = { + provider: 'auth0', + id: 'user-123', + displayName: 'Test User', + emails: [{ value: 'test@example.com' }], + }; + + let cache: jest.Mocked; + let cacheStore: Map; + let mockFetchProfile: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + cacheStore = new Map(); + cache = mockServices.cache.mock() as jest.Mocked; + // withOptions should return a working cache scoped with TTL + cache.withOptions.mockReturnValue(cache); + cache.get.mockImplementation( + async (key: string) => cacheStore.get(key) as any, + ); + cache.set.mockImplementation(async (key: string, value: unknown) => { + cacheStore.set(key, value); + }); + + mockFetchProfile = jest.fn().mockResolvedValue(mockProfile); + mockFrom.mockReturnValue({ + start: jest.fn(), + authenticate: jest.fn(), + fetchProfile: mockFetchProfile, + } as any); + + mockExecuteRefresh.mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + params: { + token_type: 'bearer', + scope: 'openid profile', + expires_in: 3600, + id_token: 'id-token-value', + }, + }); + }); + + it('should fetch profile on cache miss and cache the result', async () => { + const authenticator = createAuth0Authenticator({ cache }); + const ctx = authenticator.initialize({ + callbackUrl: 'http://localhost/callback', + config: mockConfig, + }); + + const result = await authenticator.refresh( + { + refreshToken: 'my-refresh-token', + scope: 'openid profile', + req: {} as express.Request, + }, + ctx, + ); + + expect(mockExecuteRefresh).toHaveBeenCalledWith( + expect.anything(), + 'my-refresh-token', + 'openid profile', + ); + expect(mockFetchProfile).toHaveBeenCalledWith('new-access-token'); + expect(result.fullProfile).toEqual(mockProfile); + expect(result.session).toEqual({ + accessToken: 'new-access-token', + tokenType: 'bearer', + scope: 'openid profile', + expiresInSeconds: 3600, + idToken: 'id-token-value', + refreshToken: 'new-refresh-token', + }); + }); + + it('should return cached profile on cache hit without calling fetchProfile', async () => { + const authenticator = createAuth0Authenticator({ cache }); + const ctx = authenticator.initialize({ + callbackUrl: 'http://localhost/callback', + config: mockConfig, + }); + + // First call - populates cache + await authenticator.refresh( + { + refreshToken: 'my-refresh-token', + scope: 'openid profile', + req: {} as express.Request, + }, + ctx, + ); + + // Second call - should use cache + mockFetchProfile.mockClear(); + mockExecuteRefresh.mockClear(); + mockExecuteRefresh.mockResolvedValue({ + accessToken: 'another-access-token', + refreshToken: 'another-refresh-token', + params: { + token_type: 'bearer', + scope: 'openid profile', + expires_in: 3600, + id_token: 'id-token-2', + }, + }); + + const result = await authenticator.refresh( + { + refreshToken: 'my-refresh-token', + scope: 'openid profile', + req: {} as express.Request, + }, + ctx, + ); + + // Token refresh still happens + expect(mockExecuteRefresh).toHaveBeenCalledTimes(1); + // But profile fetch is skipped + expect(mockFetchProfile).not.toHaveBeenCalled(); + expect(result.fullProfile).toEqual(mockProfile); + // Session uses fresh token data + expect(result.session.accessToken).toBe('another-access-token'); + expect(result.session.idToken).toBe('id-token-2'); + }); + + it('should fetch profile again when refresh token changes', async () => { + const authenticator = createAuth0Authenticator({ cache }); + const ctx = authenticator.initialize({ + callbackUrl: 'http://localhost/callback', + config: mockConfig, + }); + + // First call with token A + await authenticator.refresh( + { + refreshToken: 'token-a', + scope: 'openid profile', + req: {} as express.Request, + }, + ctx, + ); + + expect(mockFetchProfile).toHaveBeenCalledTimes(1); + + // Second call with token B - different session, should miss cache + const updatedProfile = { ...mockProfile, displayName: 'Updated User' }; + mockFetchProfile.mockResolvedValue(updatedProfile); + + const result = await authenticator.refresh( + { + refreshToken: 'token-b', + scope: 'openid profile', + req: {} as express.Request, + }, + ctx, + ); + + expect(mockFetchProfile).toHaveBeenCalledTimes(2); + expect(result.fullProfile).toEqual(updatedProfile); + }); +}); diff --git a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts index c88243d0a8..471e5ce892 100644 --- a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts @@ -14,9 +14,12 @@ * limitations under the License. */ +import { CacheService } from '@backstage/backend-plugin-api'; import express from 'express'; +import { Strategy } from 'passport'; import { createOAuthAuthenticator, + PassportHelpers, PassportOAuthAuthenticatorHelper, PassportOAuthDoneCallback, PassportProfile, @@ -24,35 +27,40 @@ import { import { Auth0Strategy } from './strategy'; /** @public */ -export const auth0Authenticator = createOAuthAuthenticator({ - defaultProfileTransform: - PassportOAuthAuthenticatorHelper.defaultProfileTransform, - initialize({ callbackUrl, config }) { - const clientID = config.getString('clientId'); - const clientSecret = config.getString('clientSecret'); - const domain = config.getString('domain'); - const audience = config.getOptionalString('audience'); - const connection = config.getOptionalString('connection'); - const connectionScope = config.getOptionalString('connectionScope'); - const callbackURL = config.getOptionalString('callbackUrl') ?? callbackUrl; - const organization = config.getOptionalString('organization'); - // Due to passport-auth0 forcing options.state = true, - // passport-oauth2 requires express-session to be installed - // so that the 'state' parameter of the oauth2 flow can be stored. - // This implementation of StateStore matches the NullStore found within - // passport-oauth2, which is the StateStore implementation used when options.state = false, - // allowing us to avoid using express-session in order to integrate with auth0. - const store = { - store(_req: express.Request, cb: any) { - cb(null, null); - }, - verify(_req: express.Request, _state: string, cb: any) { - cb(null, true); - }, - }; +export function createAuth0Authenticator(options?: { cache?: CacheService }) { + const profileCache = options?.cache?.withOptions({ + defaultTtl: { minutes: 1 }, + }); - const helper = PassportOAuthAuthenticatorHelper.from( - new Auth0Strategy( + return createOAuthAuthenticator({ + defaultProfileTransform: + PassportOAuthAuthenticatorHelper.defaultProfileTransform, + initialize({ callbackUrl, config }) { + const clientID = config.getString('clientId'); + const clientSecret = config.getString('clientSecret'); + const domain = config.getString('domain'); + const audience = config.getOptionalString('audience'); + const connection = config.getOptionalString('connection'); + const connectionScope = config.getOptionalString('connectionScope'); + const callbackURL = + config.getOptionalString('callbackUrl') ?? callbackUrl; + const organization = config.getOptionalString('organization'); + // Due to passport-auth0 forcing options.state = true, + // passport-oauth2 requires express-session to be installed + // so that the 'state' parameter of the oauth2 flow can be stored. + // This implementation of StateStore matches the NullStore found within + // passport-oauth2, which is the StateStore implementation used when options.state = false, + // allowing us to avoid using express-session in order to integrate with auth0. + const store = { + store(_req: express.Request, cb: any) { + cb(null, null); + }, + verify(_req: express.Request, _state: string, cb: any) { + cb(null, true); + }, + }; + + const strategy = new Auth0Strategy( { clientID, clientSecret, @@ -83,58 +91,93 @@ export const auth0Authenticator = createOAuthAuthenticator({ }, ); }, - ), - ); - const federated = config.getOptionalBoolean('federatedLogout') ?? false; - return { - helper, - audience, - connection, - connectionScope, - domain, - clientID, - federated, - }; - }, + ); - async start( - input, - { helper, audience, connection, connectionScope: connection_scope }, - ) { - return helper.start(input, { - accessType: 'offline', - prompt: 'consent', - ...(audience ? { audience } : {}), - ...(connection ? { connection } : {}), - ...(connection_scope ? { connection_scope } : {}), - }); - }, + const helper = PassportOAuthAuthenticatorHelper.from(strategy); + const federated = config.getOptionalBoolean('federatedLogout') ?? false; + return { + helper, + strategy: strategy as Strategy, + audience, + connection, + connectionScope, + domain, + clientID, + federated, + }; + }, - async authenticate( - input, - { helper, audience, connection, connectionScope: connection_scope }, - ) { - return helper.authenticate(input, { - ...(audience ? { audience } : {}), - ...(connection ? { connection } : {}), - ...(connection_scope ? { connection_scope } : {}), - }); - }, + async start( + input, + { helper, audience, connection, connectionScope: connection_scope }, + ) { + return helper.start(input, { + accessType: 'offline', + prompt: 'consent', + ...(audience ? { audience } : {}), + ...(connection ? { connection } : {}), + ...(connection_scope ? { connection_scope } : {}), + }); + }, - async refresh(input, { helper }) { - return helper.refresh(input); - }, + async authenticate( + input, + { helper, audience, connection, connectionScope: connection_scope }, + ) { + return helper.authenticate(input, { + ...(audience ? { audience } : {}), + ...(connection ? { connection } : {}), + ...(connection_scope ? { connection_scope } : {}), + }); + }, - async logout(input, { domain, clientID, federated }) { - const logoutUrl = new URL(`https://${domain}/v2/logout`); - if (federated) { - logoutUrl.searchParams.set('federated', ''); - } - logoutUrl.searchParams.set('client_id', clientID); - const origin = input.req.get('origin'); - if (origin) { - logoutUrl.searchParams.set('returnTo', origin); - } - return { logoutUrl: logoutUrl.toString() }; - }, -}); + async refresh(input, { helper, strategy }) { + const result = await PassportHelpers.executeRefreshTokenStrategy( + strategy, + input.refreshToken, + input.scope, + ); + + const cacheKey = `auth0-profile:${input.refreshToken}`; + let fullProfile = (await profileCache?.get(cacheKey)) as + | PassportProfile + | undefined; + + if (!fullProfile) { + fullProfile = await helper.fetchProfile(result.accessToken); + await profileCache?.set( + cacheKey, + JSON.parse(JSON.stringify(fullProfile)), + ); + } + + return { + fullProfile, + session: { + accessToken: result.accessToken, + tokenType: result.params.token_type ?? 'bearer', + scope: result.params.scope, + expiresInSeconds: result.params.expires_in, + idToken: result.params.id_token, + refreshToken: result.refreshToken, + }, + }; + }, + + async logout(input, { domain, clientID, federated }) { + const logoutUrl = new URL(`https://${domain}/v2/logout`); + if (federated) { + logoutUrl.searchParams.set('federated', ''); + } + logoutUrl.searchParams.set('client_id', clientID); + const origin = input.req.get('origin'); + if (origin) { + logoutUrl.searchParams.set('returnTo', origin); + } + return { logoutUrl: logoutUrl.toString() }; + }, + }); +} + +/** @public */ +export const auth0Authenticator = createAuth0Authenticator(); diff --git a/plugins/auth-backend-module-auth0-provider/src/index.ts b/plugins/auth-backend-module-auth0-provider/src/index.ts index eb8f4efcbe..4c5de2f15f 100644 --- a/plugins/auth-backend-module-auth0-provider/src/index.ts +++ b/plugins/auth-backend-module-auth0-provider/src/index.ts @@ -20,5 +20,5 @@ * @packageDocumentation */ -export { auth0Authenticator } from './authenticator'; +export { auth0Authenticator, createAuth0Authenticator } from './authenticator'; export { authModuleAuth0Provider as default } from './module'; diff --git a/plugins/auth-backend-module-auth0-provider/src/module.ts b/plugins/auth-backend-module-auth0-provider/src/module.ts index fbdb752780..81f2b6e9ad 100644 --- a/plugins/auth-backend-module-auth0-provider/src/module.ts +++ b/plugins/auth-backend-module-auth0-provider/src/module.ts @@ -14,13 +14,16 @@ * limitations under the License. */ -import { createBackendModule } from '@backstage/backend-plugin-api'; +import { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; import { authProvidersExtensionPoint, commonSignInResolvers, createOAuthProviderFactory, } from '@backstage/plugin-auth-node'; -import { auth0Authenticator } from './authenticator'; +import { createAuth0Authenticator } from './authenticator'; /** @public */ export const authModuleAuth0Provider = createBackendModule({ @@ -30,12 +33,13 @@ export const authModuleAuth0Provider = createBackendModule({ reg.registerInit({ deps: { providers: authProvidersExtensionPoint, + cache: coreServices.cache, }, - async init({ providers }) { + async init({ providers, cache }) { providers.registerProvider({ providerId: 'auth0', factory: createOAuthProviderFactory({ - authenticator: auth0Authenticator, + authenticator: createAuth0Authenticator({ cache }), signInResolverFactories: { ...commonSignInResolvers, },