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) <noreply@anthropic.com> Signed-off-by: MT Lewis <mtlewis@users.noreply.github.com>
This commit is contained in:
Vendored
+8
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -98,6 +98,8 @@ export const authPlugin = createBackendPlugin({
|
||||
database,
|
||||
logger,
|
||||
lifecycle,
|
||||
catalog,
|
||||
auth,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -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<OfflineAccessService> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
) {
|
||||
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<string, unknown>,
|
||||
) {
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user