From b3bbd42f915abd02ed7870f4e67d8f97f3a9cfc6 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Wed, 1 Apr 2026 17:11:05 +0100 Subject: [PATCH 1/4] auth0: cache profile API responses during token refresh Every page refresh triggers the auth0 authenticator to fetch the user profile from Auth0's /userinfo API. Auth0 enforces strict rate limits on this endpoint, causing failures at scale. Add a createAuth0Authenticator factory that accepts an optional CacheService to cache profile responses with a 1-minute TTL. The module now uses the cached variant by default. The existing auth0Authenticator export remains available for use without caching. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jack Palmer --- .changeset/auth0-cache-profile-refresh.md | 5 + .../package.json | 2 +- .../report.api.md | 20 ++ .../src/authenticator.test.ts | 227 ++++++++++++++++++ .../src/authenticator.ts | 201 ++++++++++------ .../src/index.ts | 2 +- .../src/module.ts | 12 +- 7 files changed, 384 insertions(+), 85 deletions(-) create mode 100644 .changeset/auth0-cache-profile-refresh.md create mode 100644 plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts 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, }, From ca3ef847f55d86e5a0958e4d6a6d0feeff347a1c Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Thu, 2 Apr 2026 11:35:00 +0100 Subject: [PATCH 2/4] fix(auth0): use stable sub claim as cache key instead of refresh token Auth0 rotates refresh tokens on each use, causing cache misses every time. Use the user's sub claim from the ID token as the cache key instead, which is stable per user. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jonathan Roebuck --- .../auth-backend-module-auth0-provider/src/authenticator.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts index 471e5ce892..8c8b200793 100644 --- a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts @@ -138,7 +138,10 @@ export function createAuth0Authenticator(options?: { cache?: CacheService }) { input.scope, ); - const cacheKey = `auth0-profile:${input.refreshToken}`; + const sub = JSON.parse( + Buffer.from(result.params.id_token.split('.')[1], 'base64').toString(), + ).sub; + const cacheKey = `auth0-profile:${sub}`; let fullProfile = (await profileCache?.get(cacheKey)) as | PassportProfile | undefined; From 44a42bf8e7aa16a064a0bdbf0e8b546e90635159 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Thu, 2 Apr 2026 13:43:16 +0100 Subject: [PATCH 3/4] fix(auth0): use jose to decode id_token sub claim for cache key Replace manual base64 JWT decoding with jose's decodeJwt for correctness and consistency with other auth modules. Add jose as a dependency. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jack Palmer --- .../package.json | 1 + .../src/authenticator.test.ts | 34 ++++++++++++++----- .../src/authenticator.ts | 5 ++- yarn.lock | 1 + 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/plugins/auth-backend-module-auth0-provider/package.json b/plugins/auth-backend-module-auth0-provider/package.json index f041c770bd..6818b23e57 100644 --- a/plugins/auth-backend-module-auth0-provider/package.json +++ b/plugins/auth-backend-module-auth0-provider/package.json @@ -39,6 +39,7 @@ "@backstage/plugin-auth-node": "workspace:^", "@types/passport": "^1.0.3", "express": "^4.22.0", + "jose": "^5.0.0", "passport": "^0.7.0", "passport-auth0": "^1.4.3", "passport-oauth2": "^1.6.1" diff --git a/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts b/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts index 6d783fc96f..0fdeb87cdb 100644 --- a/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts @@ -63,6 +63,15 @@ describe('createAuth0Authenticator', () => { }, }); + // Create a minimal valid JWT with a sub claim for decodeJwt + function createTestIdToken(sub: string): string { + const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString( + 'base64url', + ); + const payload = Buffer.from(JSON.stringify({ sub })).toString('base64url'); + return `${header}.${payload}.`; + } + const mockProfile = { provider: 'auth0', id: 'user-123', @@ -101,7 +110,7 @@ describe('createAuth0Authenticator', () => { token_type: 'bearer', scope: 'openid profile', expires_in: 3600, - id_token: 'id-token-value', + id_token: createTestIdToken('auth0|user-123'), }, }); }); @@ -134,7 +143,7 @@ describe('createAuth0Authenticator', () => { tokenType: 'bearer', scope: 'openid profile', expiresInSeconds: 3600, - idToken: 'id-token-value', + idToken: createTestIdToken('auth0|user-123'), refreshToken: 'new-refresh-token', }); }); @@ -166,7 +175,7 @@ describe('createAuth0Authenticator', () => { token_type: 'bearer', scope: 'openid profile', expires_in: 3600, - id_token: 'id-token-2', + id_token: createTestIdToken('auth0|user-123'), }, }); @@ -181,22 +190,21 @@ describe('createAuth0Authenticator', () => { // Token refresh still happens expect(mockExecuteRefresh).toHaveBeenCalledTimes(1); - // But profile fetch is skipped + // But profile fetch is skipped because same sub 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 () => { + it('should fetch profile again when sub claim changes', async () => { const authenticator = createAuth0Authenticator({ cache }); const ctx = authenticator.initialize({ callbackUrl: 'http://localhost/callback', config: mockConfig, }); - // First call with token A + // First call with user-123 await authenticator.refresh( { refreshToken: 'token-a', @@ -208,7 +216,17 @@ describe('createAuth0Authenticator', () => { expect(mockFetchProfile).toHaveBeenCalledTimes(1); - // Second call with token B - different session, should miss cache + // Second call with a different sub - different user, should miss cache + mockExecuteRefresh.mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + params: { + token_type: 'bearer', + scope: 'openid profile', + expires_in: 3600, + id_token: createTestIdToken('auth0|user-456'), + }, + }); const updatedProfile = { ...mockProfile, displayName: 'Updated User' }; mockFetchProfile.mockResolvedValue(updatedProfile); diff --git a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts index 8c8b200793..6390324869 100644 --- a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts @@ -16,6 +16,7 @@ import { CacheService } from '@backstage/backend-plugin-api'; import express from 'express'; +import { decodeJwt } from 'jose'; import { Strategy } from 'passport'; import { createOAuthAuthenticator, @@ -138,9 +139,7 @@ export function createAuth0Authenticator(options?: { cache?: CacheService }) { input.scope, ); - const sub = JSON.parse( - Buffer.from(result.params.id_token.split('.')[1], 'base64').toString(), - ).sub; + const { sub } = decodeJwt(result.params.id_token); const cacheKey = `auth0-profile:${sub}`; let fullProfile = (await profileCache?.get(cacheKey)) as | PassportProfile diff --git a/yarn.lock b/yarn.lock index a4ea35b988..9ad909ea8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4199,6 +4199,7 @@ __metadata: "@types/passport-auth0": "npm:^1.0.5" "@types/passport-oauth2": "npm:^1.4.15" express: "npm:^4.22.0" + jose: "npm:^5.0.0" passport: "npm:^0.7.0" passport-auth0: "npm:^1.4.3" passport-oauth2: "npm:^1.6.1" From dfde6becee383b07cebd5890f485b512c17b7418 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Tue, 7 Apr 2026 14:01:45 +0100 Subject: [PATCH 4/4] fix(auth0): skip profile cache when id_token has no sub claim When the JWT id_token lacks a sub claim, the cache key would be auth0-profile:undefined, causing all users without a sub to share the same cached profile. Now skips caching entirely when sub is missing and fetches the profile directly. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jack Palmer --- .../src/authenticator.test.ts | 43 +++++++++++++++++++ .../src/authenticator.ts | 19 ++++---- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts b/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts index 0fdeb87cdb..aebb73812c 100644 --- a/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts @@ -242,4 +242,47 @@ describe('createAuth0Authenticator', () => { expect(mockFetchProfile).toHaveBeenCalledTimes(2); expect(result.fullProfile).toEqual(updatedProfile); }); + + it('should skip cache when id_token has no sub claim', async () => { + const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString( + 'base64url', + ); + const payload = Buffer.from(JSON.stringify({ aud: 'test' })).toString( + 'base64url', + ); + const idTokenWithoutSub = `${header}.${payload}.`; + + mockExecuteRefresh.mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + params: { + token_type: 'bearer', + scope: 'openid profile', + expires_in: 3600, + id_token: idTokenWithoutSub, + }, + }); + + 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, + ); + + // Profile is fetched directly + expect(mockFetchProfile).toHaveBeenCalledWith('new-access-token'); + expect(result.fullProfile).toEqual(mockProfile); + // Cache is never read or written + expect(cache.get).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); }); diff --git a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts index 6390324869..c22705a165 100644 --- a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts @@ -140,17 +140,20 @@ export function createAuth0Authenticator(options?: { cache?: CacheService }) { ); const { sub } = decodeJwt(result.params.id_token); - const cacheKey = `auth0-profile:${sub}`; - let fullProfile = (await profileCache?.get(cacheKey)) as - | PassportProfile - | undefined; + const cacheKey = sub ? `auth0-profile:${sub}` : undefined; + + let fullProfile = cacheKey + ? ((await profileCache?.get(cacheKey)) as PassportProfile | undefined) + : undefined; if (!fullProfile) { fullProfile = await helper.fetchProfile(result.accessToken); - await profileCache?.set( - cacheKey, - JSON.parse(JSON.stringify(fullProfile)), - ); + if (cacheKey) { + await profileCache?.set( + cacheKey, + JSON.parse(JSON.stringify(fullProfile)), + ); + } } return {