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) <noreply@anthropic.com>
Signed-off-by: Jack Palmer <jackpalmer@spotify.com>
This commit is contained in:
Jack Palmer
2026-04-01 17:11:05 +01:00
parent 386972f871
commit b3bbd42f91
7 changed files with 384 additions and 85 deletions
@@ -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.
@@ -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"
@@ -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
>;
```
@@ -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<CacheService>;
let cacheStore: Map<string, unknown>;
let mockFetchProfile: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
cacheStore = new Map();
cache = mockServices.cache.mock() as jest.Mocked<CacheService>;
// 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);
});
});
@@ -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();
@@ -20,5 +20,5 @@
* @packageDocumentation
*/
export { auth0Authenticator } from './authenticator';
export { auth0Authenticator, createAuth0Authenticator } from './authenticator';
export { authModuleAuth0Provider as default } from './module';
@@ -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,
},