From 27f24a9a0f76dec9633fe7c6fe2b4c3653fc3c26 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Wed, 6 May 2026 18:07:00 +0100 Subject: [PATCH 1/2] feat(auth-backend): validate catalog user existence on refresh token usage Add `experimentalRefreshToken.validateCatalogUserExistence` config option that checks whether the user's catalog entity still exists before issuing a new access token during refresh. If the user has been removed from the catalog (e.g. offboarded), the refresh is rejected and the session is revoked. Transient catalog errors reject the refresh but preserve the session for retry. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: MT Lewis --- .../validate-catalog-user-on-refresh.md | 5 + plugins/auth-backend/config.d.ts | 8 + plugins/auth-backend/src/authPlugin.ts | 2 + .../src/service/OfflineAccessService.ts | 53 +++++- .../src/service/OidcRouter.test.ts | 166 +++++++++++++++++- 5 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 .changeset/validate-catalog-user-on-refresh.md diff --git a/.changeset/validate-catalog-user-on-refresh.md b/.changeset/validate-catalog-user-on-refresh.md new file mode 100644 index 0000000000..6c39f13e1e --- /dev/null +++ b/.changeset/validate-catalog-user-on-refresh.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Added a new `auth.experimentalRefreshToken.validateCatalogUserExistence` config option. When enabled, refresh token usage will verify that the user's catalog entity still exists before issuing a new access token. If the user has been removed from the catalog, the refresh is rejected and the session is revoked. Transient catalog errors reject the refresh but preserve the session for retry. diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index b35e39d90d..a0d4509c23 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -133,6 +133,14 @@ export interface Config { * @visibility backend */ maxTokensPerUser?: number; + /** + * Whether to validate that the user's catalog entity exists when + * refreshing a token. When enabled, tokens for users removed from + * the catalog will be rejected and revoked. + * @default false + * @visibility backend + */ + validateCatalogUserExistence?: boolean; }; /** diff --git a/plugins/auth-backend/src/authPlugin.ts b/plugins/auth-backend/src/authPlugin.ts index 3e721d18a7..7b74724321 100644 --- a/plugins/auth-backend/src/authPlugin.ts +++ b/plugins/auth-backend/src/authPlugin.ts @@ -98,6 +98,8 @@ export const authPlugin = createBackendPlugin({ database, logger, lifecycle, + catalog, + auth, }) : undefined; diff --git a/plugins/auth-backend/src/service/OfflineAccessService.ts b/plugins/auth-backend/src/service/OfflineAccessService.ts index f9883fce3a..240d57317c 100644 --- a/plugins/auth-backend/src/service/OfflineAccessService.ts +++ b/plugins/auth-backend/src/service/OfflineAccessService.ts @@ -15,6 +15,7 @@ */ import { + AuthService, DatabaseService, LifecycleService, LoggerService, @@ -23,6 +24,7 @@ import { import { AuthenticationError } from '@backstage/errors'; import { readDurationFromConfig } from '@backstage/config'; import { durationToMilliseconds } from '@backstage/types'; +import { CatalogService } from '@backstage/plugin-catalog-node'; import { randomUUID as uuid } from 'node:crypto'; import { OfflineSessionDatabase } from '../database/OfflineSessionDatabase'; import { @@ -39,12 +41,17 @@ import { TokenIssuer } from '../identity/types'; export class OfflineAccessService { readonly #offlineSessionDb: OfflineSessionDatabase; readonly #logger: LoggerService; + readonly #validateCatalogUserExistence: boolean; + readonly #catalog: CatalogService; + readonly #auth: AuthService; static async create(options: { config: RootConfigService; database: DatabaseService; logger: LoggerService; lifecycle: LifecycleService; + catalog: CatalogService; + auth: AuthService; }): Promise { const { config, database, logger, lifecycle } = options; @@ -98,6 +105,11 @@ export class OfflineAccessService { ); } + const validateCatalogUserExistence = + config.getOptionalBoolean( + 'auth.experimentalRefreshToken.validateCatalogUserExistence', + ) ?? false; + const knex = await database.getClient(); if ( @@ -135,15 +147,27 @@ export class OfflineAccessService { clearInterval(cleanupInterval); }); - return new OfflineAccessService(offlineSessionDb, logger); + return new OfflineAccessService( + offlineSessionDb, + logger, + validateCatalogUserExistence, + options.catalog, + options.auth, + ); } private constructor( offlineSessionDb: OfflineSessionDatabase, logger: LoggerService, + validateCatalogUserExistence: boolean, + catalog: CatalogService, + auth: AuthService, ) { this.#offlineSessionDb = offlineSessionDb; this.#logger = logger; + this.#validateCatalogUserExistence = validateCatalogUserExistence; + this.#catalog = catalog; + this.#auth = auth; } /** @@ -212,6 +236,33 @@ export class OfflineAccessService { throw new AuthenticationError('Invalid refresh token'); } + if (this.#validateCatalogUserExistence) { + try { + const entity = await this.#catalog.getEntityByRef( + session.userEntityRef, + { credentials: await this.#auth.getOwnServiceCredentials() }, + ); + if (!entity) { + this.#logger.info( + `Rejecting refresh for user ${session.userEntityRef} - catalog entity not found, revoking session ${sessionId}`, + ); + await this.#offlineSessionDb.deleteSession(sessionId); + throw new AuthenticationError( + 'User entity no longer exists in the catalog', + ); + } + } catch (error) { + if (error instanceof AuthenticationError) { + throw error; + } + this.#logger.warn( + `Failed to validate catalog user existence for ${session.userEntityRef}, rejecting refresh`, + error, + ); + throw new AuthenticationError('Unable to validate user existence'); + } + } + const { token: newRefreshToken, hash: newHash } = await generateRefreshToken(sessionId); diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index 6b6ba0b8ff..2568e48c29 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -36,6 +36,7 @@ import { OidcService } from '../service/OidcService'; import { TokenIssuer } from '../identity/types'; import { OfflineAccessService } from './OfflineAccessService'; import { CimdClientInfo, validateCimdUrl } from './CimdClient'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; jest.mock('./CimdClient', () => { const actual = jest.requireActual('./CimdClient'); @@ -137,7 +138,10 @@ describe('OidcRouter', () => { }; } - async function createRouterWithOfflineAccess(databaseId: TestDatabaseId) { + async function createRouterWithOfflineAccess( + databaseId: TestDatabaseId, + refreshTokenConfig?: Record, + ) { const knex = await databases.init(databaseId); await knex.migrate.latest({ @@ -166,6 +170,7 @@ describe('OidcRouter', () => { const mockAuth = mockServices.auth.mock(); const mockHttpAuth = mockServices.httpAuth.mock(); + const mockCatalog = catalogServiceMock.mock(); const mockConfig = mockServices.rootConfig({ data: { auth: { @@ -174,6 +179,7 @@ describe('OidcRouter', () => { }, experimentalRefreshToken: { enabled: true, + ...refreshTokenConfig, }, }, }, @@ -186,6 +192,8 @@ describe('OidcRouter', () => { database: { getClient: async () => knex }, logger: mockServices.logger.mock(), lifecycle: mockLifecycle, + catalog: mockCatalog, + auth: mockAuth, }); const oidcService = OidcService.create({ @@ -221,6 +229,7 @@ describe('OidcRouter', () => { userInfo: userInfoDatabase, service: oidcService, tokenIssuer: mockTokenIssuer, + catalog: mockCatalog, }, }; } @@ -922,8 +931,14 @@ describe('OidcRouter', () => { }); describe('refresh tokens', () => { - async function doAuthFlowWithOfflineAccess(databaseId_: TestDatabaseId) { - const result = await createRouterWithOfflineAccess(databaseId_); + async function doAuthFlowWithOfflineAccess( + databaseId_: TestDatabaseId, + refreshTokenConfig?: Record, + ) { + const result = await createRouterWithOfflineAccess( + databaseId_, + refreshTokenConfig, + ); const { mocks: { auth, service, tokenIssuer, httpAuth }, router, @@ -1121,6 +1136,151 @@ describe('OidcRouter', () => { }) .expect(400); }); + + describe('catalog user validation', () => { + it('should refresh a token when catalog user exists', async () => { + const { server, tokenResponse, mocks } = + await doAuthFlowWithOfflineAccess(databaseId, { + validateCatalogUserExistence: true, + }); + + mocks.catalog.getEntityByRef.mockResolvedValueOnce({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'test-user', namespace: 'default' }, + spec: {}, + }); + mocks.tokenIssuer.issueToken.mockResolvedValue({ + token: 'mock-refreshed-token', + }); + + const refreshResponse = await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'refresh_token', + refresh_token: tokenResponse.body.refresh_token, + }) + .expect(200); + + expect(refreshResponse.body).toEqual({ + access_token: 'mock-refreshed-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: expect.any(String), + }); + }); + + it('should reject refresh when catalog user does not exist', async () => { + const { server, tokenResponse, mocks } = + await doAuthFlowWithOfflineAccess(databaseId, { + validateCatalogUserExistence: true, + }); + + mocks.catalog.getEntityByRef.mockResolvedValueOnce(undefined); + + await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'refresh_token', + refresh_token: tokenResponse.body.refresh_token, + }) + .expect(400); + }); + + it('should reject refresh when catalog is unavailable', async () => { + const { server, tokenResponse, mocks } = + await doAuthFlowWithOfflineAccess(databaseId, { + validateCatalogUserExistence: true, + }); + + mocks.catalog.getEntityByRef.mockRejectedValueOnce( + new Error('Catalog unavailable'), + ); + + await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'refresh_token', + refresh_token: tokenResponse.body.refresh_token, + }) + .expect(400); + }); + + it('should allow retry after transient catalog failure', async () => { + const { server, tokenResponse, mocks } = + await doAuthFlowWithOfflineAccess(databaseId, { + validateCatalogUserExistence: true, + }); + + mocks.catalog.getEntityByRef.mockRejectedValueOnce( + new Error('Catalog unavailable'), + ); + + // First refresh fails due to catalog error + await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'refresh_token', + refresh_token: tokenResponse.body.refresh_token, + }) + .expect(400); + + // Retry with same token succeeds because session was preserved + mocks.catalog.getEntityByRef.mockResolvedValueOnce({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'test-user', namespace: 'default' }, + spec: {}, + }); + mocks.tokenIssuer.issueToken.mockResolvedValue({ + token: 'mock-refreshed-token', + }); + + const retryResponse = await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'refresh_token', + refresh_token: tokenResponse.body.refresh_token, + }) + .expect(200); + + expect(retryResponse.body.access_token).toBe('mock-refreshed-token'); + }); + + it('should not allow retry after user entity not found', async () => { + const { server, tokenResponse, mocks } = + await doAuthFlowWithOfflineAccess(databaseId, { + validateCatalogUserExistence: true, + }); + + mocks.catalog.getEntityByRef.mockResolvedValueOnce(undefined); + + // First refresh fails and session is revoked + await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'refresh_token', + refresh_token: tokenResponse.body.refresh_token, + }) + .expect(400); + + // Retry fails because session was deleted + mocks.catalog.getEntityByRef.mockResolvedValueOnce({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'test-user', namespace: 'default' }, + spec: {}, + }); + + await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'refresh_token', + refresh_token: tokenResponse.body.refresh_token, + }) + .expect(400); + }); + }); }); describe('CIMD metadata endpoint', () => { From ed4ffaa3b39b615b725fef06606e1d5709319313 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Thu, 7 May 2026 16:51:35 +0100 Subject: [PATCH 2/2] fix(auth-backend): default catalog presence check to on, rename config Address review feedback: flip the catalog user existence check to enabled by default and rename the config option to `dangerouslyDisableCatalogPresenceCheck` as an escape hatch. Also use `error.name` instead of `instanceof` for cross-realm error safety. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: MT Lewis --- .../validate-catalog-user-on-refresh.md | 2 +- plugins/auth-backend/config.d.ts | 11 ++- .../src/service/OfflineAccessService.ts | 17 ++-- .../src/service/OidcRouter.test.ts | 79 ++++++++----------- 4 files changed, 52 insertions(+), 57 deletions(-) diff --git a/.changeset/validate-catalog-user-on-refresh.md b/.changeset/validate-catalog-user-on-refresh.md index 6c39f13e1e..3647b2b61d 100644 --- a/.changeset/validate-catalog-user-on-refresh.md +++ b/.changeset/validate-catalog-user-on-refresh.md @@ -2,4 +2,4 @@ '@backstage/plugin-auth-backend': patch --- -Added a new `auth.experimentalRefreshToken.validateCatalogUserExistence` config option. When enabled, refresh token usage will verify that the user's catalog entity still exists before issuing a new access token. If the user has been removed from the catalog, the refresh is rejected and the session is revoked. Transient catalog errors reject the refresh but preserve the session for retry. +Refresh token usage now verifies that the user's catalog entity still exists before issuing a new access token. If the user has been removed from the catalog, the refresh is rejected and the session is revoked. Transient catalog errors reject the refresh but preserve the session for retry. This check can be disabled by setting `auth.experimentalRefreshToken.dangerouslyDisableCatalogPresenceCheck` to `true`. diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index a0d4509c23..2ffe54f3f5 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -134,13 +134,16 @@ export interface Config { */ maxTokensPerUser?: number; /** - * Whether to validate that the user's catalog entity exists when - * refreshing a token. When enabled, tokens for users removed from - * the catalog will be rejected and revoked. + * Disables the check that verifies the user's catalog entity still + * exists when refreshing a token. This is an escape hatch for + * Backstage instances that allow sign-in without a corresponding + * catalog user entity. Without the check, refresh tokens for + * removed or offboarded users remain valid until they naturally + * expire. * @default false * @visibility backend */ - validateCatalogUserExistence?: boolean; + dangerouslyDisableCatalogPresenceCheck?: boolean; }; /** diff --git a/plugins/auth-backend/src/service/OfflineAccessService.ts b/plugins/auth-backend/src/service/OfflineAccessService.ts index 240d57317c..67c0b72d85 100644 --- a/plugins/auth-backend/src/service/OfflineAccessService.ts +++ b/plugins/auth-backend/src/service/OfflineAccessService.ts @@ -41,7 +41,7 @@ import { TokenIssuer } from '../identity/types'; export class OfflineAccessService { readonly #offlineSessionDb: OfflineSessionDatabase; readonly #logger: LoggerService; - readonly #validateCatalogUserExistence: boolean; + readonly #dangerouslyDisableCatalogPresenceCheck: boolean; readonly #catalog: CatalogService; readonly #auth: AuthService; @@ -105,9 +105,9 @@ export class OfflineAccessService { ); } - const validateCatalogUserExistence = + const dangerouslyDisableCatalogPresenceCheck = config.getOptionalBoolean( - 'auth.experimentalRefreshToken.validateCatalogUserExistence', + 'auth.experimentalRefreshToken.dangerouslyDisableCatalogPresenceCheck', ) ?? false; const knex = await database.getClient(); @@ -150,7 +150,7 @@ export class OfflineAccessService { return new OfflineAccessService( offlineSessionDb, logger, - validateCatalogUserExistence, + dangerouslyDisableCatalogPresenceCheck, options.catalog, options.auth, ); @@ -159,13 +159,14 @@ export class OfflineAccessService { private constructor( offlineSessionDb: OfflineSessionDatabase, logger: LoggerService, - validateCatalogUserExistence: boolean, + dangerouslyDisableCatalogPresenceCheck: boolean, catalog: CatalogService, auth: AuthService, ) { this.#offlineSessionDb = offlineSessionDb; this.#logger = logger; - this.#validateCatalogUserExistence = validateCatalogUserExistence; + this.#dangerouslyDisableCatalogPresenceCheck = + dangerouslyDisableCatalogPresenceCheck; this.#catalog = catalog; this.#auth = auth; } @@ -236,7 +237,7 @@ export class OfflineAccessService { throw new AuthenticationError('Invalid refresh token'); } - if (this.#validateCatalogUserExistence) { + if (!this.#dangerouslyDisableCatalogPresenceCheck) { try { const entity = await this.#catalog.getEntityByRef( session.userEntityRef, @@ -252,7 +253,7 @@ export class OfflineAccessService { ); } } catch (error) { - if (error instanceof AuthenticationError) { + if (error.name === 'AuthenticationError') { throw error; } this.#logger.warn( diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index 2568e48c29..41882bf597 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -171,6 +171,12 @@ describe('OidcRouter', () => { const mockAuth = mockServices.auth.mock(); const mockHttpAuth = mockServices.httpAuth.mock(); const mockCatalog = catalogServiceMock.mock(); + mockCatalog.getEntityByRef.mockResolvedValue({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'test-user', namespace: 'default' }, + spec: {}, + }); const mockConfig = mockServices.rootConfig({ data: { auth: { @@ -1138,43 +1144,9 @@ describe('OidcRouter', () => { }); describe('catalog user validation', () => { - it('should refresh a token when catalog user exists', async () => { - const { server, tokenResponse, mocks } = - await doAuthFlowWithOfflineAccess(databaseId, { - validateCatalogUserExistence: true, - }); - - mocks.catalog.getEntityByRef.mockResolvedValueOnce({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', - metadata: { name: 'test-user', namespace: 'default' }, - spec: {}, - }); - mocks.tokenIssuer.issueToken.mockResolvedValue({ - token: 'mock-refreshed-token', - }); - - const refreshResponse = await request(server) - .post('/api/auth/v1/token') - .send({ - grant_type: 'refresh_token', - refresh_token: tokenResponse.body.refresh_token, - }) - .expect(200); - - expect(refreshResponse.body).toEqual({ - access_token: 'mock-refreshed-token', - token_type: 'Bearer', - expires_in: 3600, - refresh_token: expect.any(String), - }); - }); - it('should reject refresh when catalog user does not exist', async () => { const { server, tokenResponse, mocks } = - await doAuthFlowWithOfflineAccess(databaseId, { - validateCatalogUserExistence: true, - }); + await doAuthFlowWithOfflineAccess(databaseId); mocks.catalog.getEntityByRef.mockResolvedValueOnce(undefined); @@ -1189,9 +1161,7 @@ describe('OidcRouter', () => { it('should reject refresh when catalog is unavailable', async () => { const { server, tokenResponse, mocks } = - await doAuthFlowWithOfflineAccess(databaseId, { - validateCatalogUserExistence: true, - }); + await doAuthFlowWithOfflineAccess(databaseId); mocks.catalog.getEntityByRef.mockRejectedValueOnce( new Error('Catalog unavailable'), @@ -1208,9 +1178,7 @@ describe('OidcRouter', () => { it('should allow retry after transient catalog failure', async () => { const { server, tokenResponse, mocks } = - await doAuthFlowWithOfflineAccess(databaseId, { - validateCatalogUserExistence: true, - }); + await doAuthFlowWithOfflineAccess(databaseId); mocks.catalog.getEntityByRef.mockRejectedValueOnce( new Error('Catalog unavailable'), @@ -1249,9 +1217,7 @@ describe('OidcRouter', () => { it('should not allow retry after user entity not found', async () => { const { server, tokenResponse, mocks } = - await doAuthFlowWithOfflineAccess(databaseId, { - validateCatalogUserExistence: true, - }); + await doAuthFlowWithOfflineAccess(databaseId); mocks.catalog.getEntityByRef.mockResolvedValueOnce(undefined); @@ -1280,6 +1246,31 @@ describe('OidcRouter', () => { }) .expect(400); }); + + it('should skip catalog check when dangerouslyDisableCatalogPresenceCheck is set', async () => { + const { server, tokenResponse, mocks } = + await doAuthFlowWithOfflineAccess(databaseId, { + dangerouslyDisableCatalogPresenceCheck: true, + }); + + mocks.catalog.getEntityByRef.mockResolvedValueOnce(undefined); + mocks.tokenIssuer.issueToken.mockResolvedValue({ + token: 'mock-refreshed-token', + }); + + const refreshResponse = await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'refresh_token', + refresh_token: tokenResponse.body.refresh_token, + }) + .expect(200); + + expect(refreshResponse.body.access_token).toBe( + 'mock-refreshed-token', + ); + expect(mocks.catalog.getEntityByRef).not.toHaveBeenCalled(); + }); }); });