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:
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user