From 8495b18507b3dca141abacd543db6ca6ce7b25fe Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Thu, 20 Mar 2025 12:00:23 +0100 Subject: [PATCH 01/15] feat: add external token decorator service Signed-off-by: Juan Pablo Garcia Ripa --- .changeset/thirty-rules-press.md | 5 + docs/auth/service-to-service-auth.md | 31 +++- packages/backend-defaults/report-auth.api.md | 20 +++ .../auth/authServiceFactory.test.ts | 63 +++++++- .../entrypoints/auth/authServiceFactory.ts | 26 +++- .../external/ExternalTokenHandler.test.ts | 144 ++++++++++++++++++ .../auth/external/ExternalTokenHandler.ts | 26 +++- .../src/entrypoints/auth/index.ts | 3 + .../auth/plugin/PluginTokenHandler.ts | 2 +- 9 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 .changeset/thirty-rules-press.md diff --git a/.changeset/thirty-rules-press.md b/.changeset/thirty-rules-press.md new file mode 100644 index 0000000000..f4758da67b --- /dev/null +++ b/.changeset/thirty-rules-press.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': minor +--- + +Add a new `externalTokenHandlerDecoratorServiceRef` to allow custom external token validations diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index d35aed364d..e821aeb0c7 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -414,9 +414,13 @@ Each entry has one or more of the following fields: ## Adding custom or logic for validation and issuing of tokens -The `pluginTokenHandlerDecoratorServiceRef` can be used to decorate the existing token handler without having to re-implement the entire `AuthService` implementation. +The `pluginTokenHandlerDecoratorServiceRef` and `externalTokenHandlerDecoratorServiceRef` can be used to decorate the existing token handler without having to re-implement the entire `AuthService` implementation. This is particularly useful when you want to add additional logic to the handler, such as logging or metrics or custom token validation. +### PluginTokenHandler decoration + +The `pluginTokenHandlerDecoratorServiceRef` can be used to decorate the default PluginTokenHandler used for create and verify tokens from plugins. + The `PluginTokenHandler` interface has two methods: - `issueToken`: This method is used to issue a token for a plugin. It takes in the `pluginId` and `targetPluginId` as arguments, and an optional `limitedUserToken` object which can be used to issue a token on behalf of another user. The method returns a promise that resolves to an object containing the issued token. @@ -439,3 +443,28 @@ const decoratedPluginTokenHandler = createServiceFactory({ }, }); ``` + +### ExternalTokenHandler decoration + +The `externalTokenHandlerDecoratorServiceRef` can be used to decorate the default ExternalTokenHandler used for verify tokens from external callers. + +The `ExternalTokenHandler` interface has one methods: + +- `verifyToken`: This method is used to verify a token. It takes in the token as an argument and returns a promise that resolves to an object containing the subject of the token and an optional limited user token. + +```ts +import { + ExternalTokenHandler, + externalTokenHandlerDecoratorServiceRef, +} from '@backstage/backend-defaults/auth'; +import { createServiceFactory } from '@backstage/backend-plugin-api'; + +const decoratedPluginTokenHandler = createServiceFactory({ + service: externalTokenHandlerDecoratorServiceRef, + deps: {}, + async factory() { + return (defaultImplementation: ExternalTokenHandler) => + new CustomTokenHandler(defaultImplementation); + }, +}); +``` diff --git a/packages/backend-defaults/report-auth.api.md b/packages/backend-defaults/report-auth.api.md index 1883e65a6a..56a973813d 100644 --- a/packages/backend-defaults/report-auth.api.md +++ b/packages/backend-defaults/report-auth.api.md @@ -4,6 +4,7 @@ ```ts import { AuthService } from '@backstage/backend-plugin-api'; +import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api'; import { ServiceFactory } from '@backstage/backend-plugin-api'; import { ServiceRef } from '@backstage/backend-plugin-api'; @@ -14,6 +15,25 @@ export const authServiceFactory: ServiceFactory< 'singleton' >; +// @public +export interface ExternalTokenHandler { + // (undocumented) + verifyToken(token: string): Promise< + | { + subject: string; + accessRestrictions?: BackstagePrincipalAccessRestrictions; + } + | undefined + >; +} + +// @public +export const externalTokenHandlerDecoratorServiceRef: ServiceRef< + (defaultImplementation: ExternalTokenHandler) => ExternalTokenHandler, + 'plugin', + 'singleton' +>; + // @public export interface PluginTokenHandler { // (undocumented) diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts index a244614135..990668083f 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts @@ -21,6 +21,8 @@ import { } from '@backstage/backend-test-utils'; import { authServiceFactory, + externalTokenHandlersServiceRef, + // externalTokenHandlersServiceRef, pluginTokenHandlerDecoratorServiceRef, } from './authServiceFactory'; import { base64url, decodeJwt } from 'jose'; @@ -30,6 +32,11 @@ import { setupServer } from 'msw/node'; import { toInternalBackstageCredentials } from './helpers'; import { PluginTokenHandler } from './plugin/PluginTokenHandler'; import { createServiceFactory } from '@backstage/backend-plugin-api'; +import { AccessRestriptionsMap, TokenHandler } from './external/types'; +import { Config } from '@backstage/config'; +import { x } from 'tar'; +// import { ExternalTokenHandler } from './external/ExternalTokenHandler'; +// import { TokenHandler } from './external/types'; const server = setupServer(); @@ -58,6 +65,13 @@ const mockDeps = [ subject: 'unlimited-static-subject', }, }, + { + type: 'custom', + options: { + [`custom-config`]: 'custom-config', + foo: 'bar', + }, + }, ], }, }, @@ -450,8 +464,55 @@ describe('authServiceFactory', () => { dependencies: [...mockDeps, customPluginTokenHandler], }); const searchAuth = await tester.getSubject('search'); - searchAuth.authenticate('unlimited-static-token'); + await searchAuth.authenticate('unlimited-static-token'); expect(customLogic).toHaveBeenCalledWith('unlimited-static-token'); }); }); + describe('add custom ExternalTokenHandler', () => { + it('should allow custom logic to be injected into the plugin token handler', async () => { + const customLogic = jest.fn(); + const customAddEntry = jest.fn(); + const customPluginTokenHandler = createServiceFactory({ + service: externalTokenHandlersServiceRef, + deps: {}, + async factory() { + return { + custom: new (class CustomHandler implements TokenHandler { + add(options: Config): void { + customAddEntry(options); + } + async verifyToken(token: string): Promise< + | { + subject: string; + allAccessRestrictions?: AccessRestriptionsMap; + } + | undefined + > { + customLogic(token); + return { + subject: 'foo', + }; + } + })(), + }; + }, + }); + const tester = ServiceFactoryTester.from(authServiceFactory, { + dependencies: [...mockDeps, customPluginTokenHandler], + }); + const searchAuth = await tester.getSubject('search'); + await searchAuth.authenticate('custom-token'); + expect(customAddEntry).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + options: expect.objectContaining({ + [`custom-config`]: 'custom-config', + foo: 'bar', + }), + }), + }), + ); + expect(customLogic).toHaveBeenCalledWith('custom-token'); + }); + }); }); diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts index 91910292e6..48f0941b12 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts @@ -20,13 +20,18 @@ import { createServiceRef, } from '@backstage/backend-plugin-api'; import { DefaultAuthService } from './DefaultAuthService'; -import { ExternalTokenHandler } from './external/ExternalTokenHandler'; +import { + // DefaultExternalTokenHandler, + ExternalTokenHandler, +} from './external/ExternalTokenHandler'; import { DefaultPluginTokenHandler, PluginTokenHandler, } from './plugin/PluginTokenHandler'; import { createPluginKeySource } from './plugin/keys/createPluginKeySource'; import { UserTokenHandler } from './user/UserTokenHandler'; +import { TokenHandler } from './external/types'; +import { Config } from '@backstage/config'; /** * @public @@ -45,6 +50,22 @@ export const pluginTokenHandlerDecoratorServiceRef = createServiceRef< }, }), }); +/** + * @public + * This service is used to decorate the default plugin token handler with custom logic. + */ +export const externalTokenHandlersServiceRef = createServiceRef<{ + [configKey: string]: (config: Config) => TokenHandler; +}>({ + id: 'core.auth.externalTokenHandlers', + multiton: true, + // defaultFactory: async service => + // createServiceFactory({ + // service, + // deps: {}, + // factory: async () => {}, + // }), +}); /** * Handles token authentication and credentials management. @@ -64,6 +85,7 @@ export const authServiceFactory = createServiceFactory({ plugin: coreServices.pluginMetadata, database: coreServices.database, pluginTokenHandlerDecorator: pluginTokenHandlerDecoratorServiceRef, + externalTokenHandlers: externalTokenHandlersServiceRef, }, async factory({ config, @@ -72,6 +94,7 @@ export const authServiceFactory = createServiceFactory({ logger, database, pluginTokenHandlerDecorator, + externalTokenHandlers, }) { const disableDefaultAuthPolicy = config.getOptionalBoolean( @@ -106,6 +129,7 @@ export const authServiceFactory = createServiceFactory({ ownPluginId: plugin.getId(), config, logger, + externalTokenHandlers, }); return new DefaultAuthService( diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.test.ts index 39b01c46b6..de9c9e162b 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.test.ts @@ -208,4 +208,148 @@ describe('ExternalTokenHandler', () => { accessRestrictions: { permissionNames: ['catalog.entity.read'] }, }); }); + it('successfully uses custom token handlers', async () => { + const factory = new FakeTokenFactory({ + issuer: 'my-company', + keyDurationSeconds: 100, + }); + + server.use( + rest.get( + 'https://example.com/.well-known/jwks.json', + async (_, res, ctx) => { + const keys = await factory.listPublicKeys(); + return res(ctx.json(keys)); + }, + ), + ); + + const customHandler: TokenHandler = { + add: jest.fn(), + verifyToken: jest.fn(), + }; + + const handler = ExternalTokenHandler.create({ + ownPluginId: 'catalog', + logger: mockServices.logger.mock(), + config: mockServices.rootConfig({ + data: { + backend: { + auth: { + externalAccess: [ + { + type: 'internal-custom', + options: { + issuer: 'my-company', + subject: 'internal-subject', + audience: 'backstage', + }, + accessRestrictions: [ + { plugin: 'catalog', permission: 'catalog.entity.read' }, + ], + }, + ], + }, + }, + }, + }), + externalTokenHandlers: [{ ['internal-custom']: customHandler }], + }); + + expect(customHandler.add).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + type: 'internal-custom', + options: { + issuer: 'my-company', + subject: 'internal-subject', + audience: 'backstage', + }, + accessRestrictions: [ + { plugin: 'catalog', permission: 'catalog.entity.read' }, + ], + }, + }), + ); + + const customToken = await factory.issueToken({ + claims: { sub: 'internal-subject' }, + }); + + await handler.verifyToken(customToken); + + expect(customHandler.verifyToken).toHaveBeenCalled(); + }); + it('should fail if config contains types not declared', async () => { + const createHandler = () => + ExternalTokenHandler.create({ + ownPluginId: 'catalog', + logger: mockServices.logger.mock(), + config: mockServices.rootConfig({ + data: { + backend: { + auth: { + externalAccess: [ + { + type: 'internal-custom', + options: { + issuer: 'my-company', + subject: 'internal-subject', + audience: 'backstage', + }, + accessRestrictions: [ + { plugin: 'catalog', permission: 'catalog.entity.read' }, + ], + }, + ], + }, + }, + }, + }), + }); + + expect(createHandler).toThrowErrorMatchingInlineSnapshot( + `"Unknown type 'internal-custom' in backend.auth.externalAccess, expected one of 'static', 'legacy', 'jwks'"`, + ); + }); + it('should show valid custom types in errors', async () => { + const createHandler = () => + ExternalTokenHandler.create({ + ownPluginId: 'catalog', + logger: mockServices.logger.mock(), + config: mockServices.rootConfig({ + data: { + backend: { + auth: { + externalAccess: [ + { + type: 'internal-custom-invalid', + options: { + issuer: 'my-company', + subject: 'internal-subject', + audience: 'backstage', + }, + accessRestrictions: [ + { plugin: 'catalog', permission: 'catalog.entity.read' }, + ], + }, + ], + }, + }, + }, + }), + externalTokenHandlers: [ + { + ['internal-custom']: { + add: jest.fn(), + verifyToken: jest.fn(), + }, + }, + ], + }); + + expect(createHandler).toThrowErrorMatchingInlineSnapshot( + `"Unknown type 'internal-custom-invalid' in backend.auth.externalAccess, expected one of 'static', 'legacy', 'jwks', 'internal-custom'"`, + ); + }); }); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.ts index 72eeae6fcf..a4c55810c3 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.ts @@ -24,6 +24,7 @@ import { LegacyTokenHandler } from './legacy'; import { StaticTokenHandler } from './static'; import { JWKSHandler } from './jwks'; import { TokenHandler } from './types'; +import { Config } from '@backstage/config'; const NEW_CONFIG_KEY = 'backend.auth.externalAccess'; const OLD_CONFIG_KEY = 'backend.auth.keys'; @@ -40,17 +41,27 @@ export class ExternalTokenHandler { ownPluginId: string; config: RootConfigService; logger: LoggerService; + externalTokenHandlers?: { + [key: string]: (config: Config) => TokenHandler; + }[]; }): ExternalTokenHandler { - const { ownPluginId, config, logger } = options; + const { + ownPluginId, + config, + logger, + externalTokenHandlers: customHandlers, + } = options; const staticHandler = new StaticTokenHandler(); const legacyHandler = new LegacyTokenHandler(); const jwksHandler = new JWKSHandler(); - const handlers: Record = { - static: staticHandler, - legacy: legacyHandler, - jwks: jwksHandler, + const handlers: Record TokenHandler> = { + static: (handlerConfig: Config) => staticHandler.add(handlerConfig), + legacy: (handlerConfig: Config) => legacyHandler.add(handlerConfig), + jwks: (handlerConfig: Config) => jwksHandler.add(handlerConfig), + ...Object.assign({}, ...(customHandlers ?? [])), }; + const configuredHandlers = []; // Load the new-style handlers const handlerConfigs = config.getOptionalConfigArray(NEW_CONFIG_KEY) ?? []; @@ -65,7 +76,8 @@ export class ExternalTokenHandler { `Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`, ); } - handler.add(handlerConfig); + configuredHandlers.push(handler(handlerConfig)); + // handler.add(handlerConfig); } // Load the old keys too @@ -80,7 +92,7 @@ export class ExternalTokenHandler { legacyHandler.addOld(handlerConfig); } - return new ExternalTokenHandler(ownPluginId, Object.values(handlers)); + return new ExternalTokenHandler(ownPluginId, configuredHandlers); } constructor( diff --git a/packages/backend-defaults/src/entrypoints/auth/index.ts b/packages/backend-defaults/src/entrypoints/auth/index.ts index e48926f0ce..51fe982891 100644 --- a/packages/backend-defaults/src/entrypoints/auth/index.ts +++ b/packages/backend-defaults/src/entrypoints/auth/index.ts @@ -17,6 +17,9 @@ export { authServiceFactory, pluginTokenHandlerDecoratorServiceRef, + externalTokenHandlerDecoratorServiceRef, } from './authServiceFactory'; +export type { ExternalTokenHandler } from './external/ExternalTokenHandler'; + export type { PluginTokenHandler } from './plugin/PluginTokenHandler'; diff --git a/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts b/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts index 9aec52bc08..b692cd2b87 100644 --- a/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts +++ b/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts @@ -46,7 +46,7 @@ type Options = { /** * @public - * Issues and verifies {@link https://backstage.iceio/docs/auth/service-to-service-auth | service-to-service tokens}. + * Issues and verifies {@link https://backstage.io/docs/auth/service-to-service-auth | service-to-service tokens}. */ export interface PluginTokenHandler { verifyToken( From 7d96f52c41cf9bca8d4ea2ab32468368c0645d40 Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Mon, 24 Mar 2025 09:08:19 +0100 Subject: [PATCH 02/15] fix some test and types Signed-off-by: Juan Pablo Garcia Ripa --- .../auth/authServiceFactory.test.ts | 103 +++++++++++++----- .../external/ExternalTokenHandler.test.ts | 9 +- .../src/entrypoints/auth/external/jwks.ts | 1 + .../src/entrypoints/auth/external/legacy.ts | 1 + .../src/entrypoints/auth/external/static.ts | 1 + .../src/entrypoints/auth/external/types.ts | 2 +- 6 files changed, 85 insertions(+), 32 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts index 990668083f..51df123555 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts @@ -22,7 +22,6 @@ import { import { authServiceFactory, externalTokenHandlersServiceRef, - // externalTokenHandlersServiceRef, pluginTokenHandlerDecoratorServiceRef, } from './authServiceFactory'; import { base64url, decodeJwt } from 'jose'; @@ -34,7 +33,6 @@ import { PluginTokenHandler } from './plugin/PluginTokenHandler'; import { createServiceFactory } from '@backstage/backend-plugin-api'; import { AccessRestriptionsMap, TokenHandler } from './external/types'; import { Config } from '@backstage/config'; -import { x } from 'tar'; // import { ExternalTokenHandler } from './external/ExternalTokenHandler'; // import { TokenHandler } from './external/types'; @@ -65,13 +63,6 @@ const mockDeps = [ subject: 'unlimited-static-subject', }, }, - { - type: 'custom', - options: { - [`custom-config`]: 'custom-config', - foo: 'bar', - }, - }, ], }, }, @@ -472,36 +463,84 @@ describe('authServiceFactory', () => { it('should allow custom logic to be injected into the plugin token handler', async () => { const customLogic = jest.fn(); const customAddEntry = jest.fn(); + const customConfig = jest.fn(); + const deps = [ + discoveryServiceFactory, + mockServices.rootConfig.factory({ + data: { + backend: { + baseUrl: 'http://localhost', + auth: { + keys: [{ secret: 'abc' }], + externalAccess: [ + { + type: 'static', + options: { + token: 'limited-static-token', + subject: 'limited-static-subject', + }, + accessRestrictions: [ + { plugin: 'catalog', permission: 'do.it' }, + ], + }, + { + type: 'static', + options: { + token: 'unlimited-static-token', + subject: 'unlimited-static-subject', + }, + }, + { + type: 'custom', + options: { + [`custom-config`]: 'custom-config', + foo: 'bar', + }, + }, + ], + }, + }, + }, + }), + ]; + + const customHandler = new (class CustomHandler implements TokenHandler { + add(options: Config): TokenHandler { + customAddEntry(options); + return this; + } + async verifyToken(token: string): Promise< + | { + subject: string; + allAccessRestrictions?: AccessRestriptionsMap; + } + | undefined + > { + customLogic(token); + return { + subject: 'foo', + }; + } + })(); + const customPluginTokenHandler = createServiceFactory({ service: externalTokenHandlersServiceRef, deps: {}, async factory() { return { - custom: new (class CustomHandler implements TokenHandler { - add(options: Config): void { - customAddEntry(options); - } - async verifyToken(token: string): Promise< - | { - subject: string; - allAccessRestrictions?: AccessRestriptionsMap; - } - | undefined - > { - customLogic(token); - return { - subject: 'foo', - }; - } - })(), + custom: (options: Config) => { + customConfig(options); + return customHandler.add(options); + }, }; }, }); const tester = ServiceFactoryTester.from(authServiceFactory, { - dependencies: [...mockDeps, customPluginTokenHandler], + dependencies: [...deps, customPluginTokenHandler], }); const searchAuth = await tester.getSubject('search'); await searchAuth.authenticate('custom-token'); + expect(customAddEntry).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ @@ -513,6 +552,16 @@ describe('authServiceFactory', () => { }), ); expect(customLogic).toHaveBeenCalledWith('custom-token'); + expect(customConfig).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + options: expect.objectContaining({ + [`custom-config`]: 'custom-config', + foo: 'bar', + }), + }), + }), + ); }); }); }); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.test.ts index de9c9e162b..3567061f03 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.test.ts @@ -228,6 +228,7 @@ describe('ExternalTokenHandler', () => { add: jest.fn(), verifyToken: jest.fn(), }; + const customHandlerCreator = jest.fn(() => customHandler); const handler = ExternalTokenHandler.create({ ownPluginId: 'catalog', @@ -253,10 +254,10 @@ describe('ExternalTokenHandler', () => { }, }, }), - externalTokenHandlers: [{ ['internal-custom']: customHandler }], + externalTokenHandlers: [{ ['internal-custom']: customHandlerCreator }], }); - expect(customHandler.add).toHaveBeenCalledWith( + expect(customHandlerCreator).toHaveBeenCalledWith( expect.objectContaining({ data: { type: 'internal-custom', @@ -340,10 +341,10 @@ describe('ExternalTokenHandler', () => { }), externalTokenHandlers: [ { - ['internal-custom']: { + ['internal-custom']: () => ({ add: jest.fn(), verifyToken: jest.fn(), - }, + }), }, ], }); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts b/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts index d88dc62a47..32271972fb 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts @@ -68,6 +68,7 @@ export class JWKSHandler implements TokenHandler { url, allAccessRestrictions, }); + return this; } async verifyToken(token: string) { diff --git a/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts b/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts index 9c60e70707..fbd96aafd7 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts @@ -40,6 +40,7 @@ export class LegacyTokenHandler implements TokenHandler { config.getString('options.subject'), allAccessRestrictions, ); + return this; } // used only for the old backend.auth.keys array diff --git a/packages/backend-defaults/src/entrypoints/auth/external/static.ts b/packages/backend-defaults/src/entrypoints/auth/external/static.ts index 1e89d8c6a1..b335b89e58 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/static.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/static.ts @@ -54,6 +54,7 @@ export class StaticTokenHandler implements TokenHandler { } this.#entries.set(token, { subject, allAccessRestrictions }); + return this; } async verifyToken(token: string) { diff --git a/packages/backend-defaults/src/entrypoints/auth/external/types.ts b/packages/backend-defaults/src/entrypoints/auth/external/types.ts index 6a1fd084ed..e3cad90e17 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/types.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/types.ts @@ -23,7 +23,7 @@ export type AccessRestriptionsMap = Map< >; export interface TokenHandler { - add(options: Config): void; + add?(options: Config): TokenHandler; verifyToken(token: string): Promise< | { subject: string; From 9377fbfb0f2449e03289f566a5ad0d3b18874a8b Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Mon, 24 Mar 2025 10:22:43 +0100 Subject: [PATCH 03/15] feat(docs): add multiton docs and fix the custom handler service Signed-off-by: Juan Pablo Garcia Ripa --- .changeset/thirty-rules-press.md | 2 +- docs/auth/service-to-service-auth.md | 22 +++++----- .../architecture/03-services.md | 29 +++++++++++++ packages/backend-defaults/report-auth.api.md | 41 ++++++++++++------- .../auth/authServiceFactory.test.ts | 4 +- .../entrypoints/auth/authServiceFactory.ts | 6 --- .../src/entrypoints/auth/external/helpers.ts | 6 +-- .../src/entrypoints/auth/external/jwks.ts | 4 +- .../src/entrypoints/auth/external/legacy.ts | 6 +-- .../src/entrypoints/auth/external/static.ts | 4 +- .../src/entrypoints/auth/external/types.ts | 12 +++++- .../src/entrypoints/auth/index.ts | 5 ++- 12 files changed, 93 insertions(+), 48 deletions(-) diff --git a/.changeset/thirty-rules-press.md b/.changeset/thirty-rules-press.md index f4758da67b..d9968317cb 100644 --- a/.changeset/thirty-rules-press.md +++ b/.changeset/thirty-rules-press.md @@ -2,4 +2,4 @@ '@backstage/backend-defaults': minor --- -Add a new `externalTokenHandlerDecoratorServiceRef` to allow custom external token validations +Add a new `externalTokenHandlersServiceRef` to allow custom external token validations diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index e821aeb0c7..8e8ef6a183 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -414,7 +414,7 @@ Each entry has one or more of the following fields: ## Adding custom or logic for validation and issuing of tokens -The `pluginTokenHandlerDecoratorServiceRef` and `externalTokenHandlerDecoratorServiceRef` can be used to decorate the existing token handler without having to re-implement the entire `AuthService` implementation. +The `pluginTokenHandlerDecoratorServiceRef` and `externalTokenHandlersServiceRef` can be used to extend the existing token handler without having to re-implement the entire `AuthService` implementation. This is particularly useful when you want to add additional logic to the handler, such as logging or metrics or custom token validation. ### PluginTokenHandler decoration @@ -446,25 +446,27 @@ const decoratedPluginTokenHandler = createServiceFactory({ ### ExternalTokenHandler decoration -The `externalTokenHandlerDecoratorServiceRef` can be used to decorate the default ExternalTokenHandler used for verify tokens from external callers. +The `externalTokenHandlersServiceRef` can be used to add custom external token handlers to the default implementation. -The `ExternalTokenHandler` interface has one methods: +The returned object should be a map of token custom types and their handler factories. The object keys will be matched to the configured `externalAccess` type in the app-config, calling the factory function with the config object for that type. Custom token handlers should implement the `TokenHandler` interface, which provides methods for verifying tokens. -- `verifyToken`: This method is used to verify a token. It takes in the token as an argument and returns a promise that resolves to an object containing the subject of the token and an optional limited user token. +For example, to add a custom token handler for a type called 'custom': ```ts import { - ExternalTokenHandler, - externalTokenHandlerDecoratorServiceRef, + TokenHandler, + externalTokenHandlersServiceRef, } from '@backstage/backend-defaults/auth'; +import { Config } from '@backstage/config'; import { createServiceFactory } from '@backstage/backend-plugin-api'; -const decoratedPluginTokenHandler = createServiceFactory({ - service: externalTokenHandlerDecoratorServiceRef, +const customExternalTokenHandlers = createServiceFactory({ + service: externalTokenHandlersServiceRef, deps: {}, async factory() { - return (defaultImplementation: ExternalTokenHandler) => - new CustomTokenHandler(defaultImplementation); + return { + custom: (config: Config) => new CustomTokenHandler(config); + }; }, }); ``` diff --git a/docs/backend-system/architecture/03-services.md b/docs/backend-system/architecture/03-services.md index cfcd606d92..3b168b81e3 100644 --- a/docs/backend-system/architecture/03-services.md +++ b/docs/backend-system/architecture/03-services.md @@ -223,6 +223,35 @@ export const customFooServiceFactory = createServiceFactory({ This allows you to provide more advanced options for the service implementation that couldn't be expressed through static configuration. It also gives users of the service implementation access to other services through dependency injection, which can be useful for their customizations. +## Multiton + +By default the service reference will point to a singleton instance of the service. This meand if a new service factory uses this reference will override the previous one. This is the most common use-case, but in some cases you may want to have multiple instances of the same service. +For some services, is desirable to extend the functionality instead of overriding it. For example, some services could have many handler to address a specific event, and you may want to add a new handler instead of overriding the previous one. In this case, you can use the `multiton` option when creating the service reference: + +```ts +// example-service-ref.ts +import { createServiceRef } from '@backstage/backend-plugin-api'; + +export interface FooService { + foo(options: FooOptions): Promise; +} + +export const fooServiceRef = createServiceRef({ + id: 'example.foo', + multiton: true, // this service ref will be an array of instances +}); +``` + +When adding this serviceRef ad dependency to a factory, the factory will receive an array of instances instead of a single instance: + +```ts +deps: {fooServices: fooServiceRef}, + factory(fooServices) { + // fooServices is an array of instances + return new Bar(fooServices); + }, +``` + ## Service Factory Options Pattern :::note Note diff --git a/packages/backend-defaults/report-auth.api.md b/packages/backend-defaults/report-auth.api.md index 56a973813d..9b7a56cff5 100644 --- a/packages/backend-defaults/report-auth.api.md +++ b/packages/backend-defaults/report-auth.api.md @@ -5,9 +5,16 @@ ```ts import { AuthService } from '@backstage/backend-plugin-api'; import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api'; +import { Config } from '@backstage/config'; import { ServiceFactory } from '@backstage/backend-plugin-api'; import { ServiceRef } from '@backstage/backend-plugin-api'; +// @public (undocumented) +export type AccessRestrictionsMap = Map< + string, // plugin ID + BackstagePrincipalAccessRestrictions +>; + // @public export const authServiceFactory: ServiceFactory< AuthService, @@ -16,22 +23,12 @@ export const authServiceFactory: ServiceFactory< >; // @public -export interface ExternalTokenHandler { - // (undocumented) - verifyToken(token: string): Promise< - | { - subject: string; - accessRestrictions?: BackstagePrincipalAccessRestrictions; - } - | undefined - >; -} - -// @public -export const externalTokenHandlerDecoratorServiceRef: ServiceRef< - (defaultImplementation: ExternalTokenHandler) => ExternalTokenHandler, +export const externalTokenHandlersServiceRef: ServiceRef< + { + [configKey: string]: (config: Config) => TokenHandler; + }, 'plugin', - 'singleton' + 'multiton' >; // @public @@ -64,5 +61,19 @@ export const pluginTokenHandlerDecoratorServiceRef: ServiceRef< 'singleton' >; +// @public +export interface TokenHandler { + // (undocumented) + add?(options: Config): TokenHandler; + // (undocumented) + verifyToken(token: string): Promise< + | { + subject: string; + allAccessRestrictions?: AccessRestrictionsMap; + } + | undefined + >; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts index 51df123555..d6cc0023a8 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts @@ -31,7 +31,7 @@ import { setupServer } from 'msw/node'; import { toInternalBackstageCredentials } from './helpers'; import { PluginTokenHandler } from './plugin/PluginTokenHandler'; import { createServiceFactory } from '@backstage/backend-plugin-api'; -import { AccessRestriptionsMap, TokenHandler } from './external/types'; +import { AccessRestrictionsMap, TokenHandler } from './external/types'; import { Config } from '@backstage/config'; // import { ExternalTokenHandler } from './external/ExternalTokenHandler'; // import { TokenHandler } from './external/types'; @@ -512,7 +512,7 @@ describe('authServiceFactory', () => { async verifyToken(token: string): Promise< | { subject: string; - allAccessRestrictions?: AccessRestriptionsMap; + allAccessRestrictions?: AccessRestrictionsMap; } | undefined > { diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts index 48f0941b12..ae8ad205b7 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts @@ -59,12 +59,6 @@ export const externalTokenHandlersServiceRef = createServiceRef<{ }>({ id: 'core.auth.externalTokenHandlers', multiton: true, - // defaultFactory: async service => - // createServiceFactory({ - // service, - // deps: {}, - // factory: async () => {}, - // }), }); /** diff --git a/packages/backend-defaults/src/entrypoints/auth/external/helpers.ts b/packages/backend-defaults/src/entrypoints/auth/external/helpers.ts index d6aa0a01ff..4a01117e28 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/helpers.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/helpers.ts @@ -15,7 +15,7 @@ */ import { Config } from '@backstage/config'; -import { AccessRestriptionsMap } from './types'; +import { AccessRestrictionsMap } from './types'; /** * Parses and returns the `accessRestrictions` configuration from an @@ -25,12 +25,12 @@ import { AccessRestriptionsMap } from './types'; */ export function readAccessRestrictionsFromConfig( externalAccessEntryConfig: Config, -): AccessRestriptionsMap | undefined { +): AccessRestrictionsMap | undefined { const configs = externalAccessEntryConfig.getOptionalConfigArray('accessRestrictions') ?? []; - const result: AccessRestriptionsMap = new Map(); + const result: AccessRestrictionsMap = new Map(); for (const config of configs) { const validKeys = ['plugin', 'permission', 'permissionAttribute']; for (const key of config.keys()) { diff --git a/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts b/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts index 32271972fb..49167bcd35 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts @@ -20,7 +20,7 @@ import { readAccessRestrictionsFromConfig, readStringOrStringArrayFromConfig, } from './helpers'; -import { AccessRestriptionsMap, TokenHandler } from './types'; +import { AccessRestrictionsMap, TokenHandler } from './types'; /** * Handles `type: jwks` access. @@ -35,7 +35,7 @@ export class JWKSHandler implements TokenHandler { subjectPrefix?: string; url: URL; jwks: JWTVerifyGetKey; - allAccessRestrictions?: AccessRestriptionsMap; + allAccessRestrictions?: AccessRestrictionsMap; }> = []; add(config: Config) { diff --git a/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts b/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts index fbd96aafd7..09d759a09e 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts @@ -17,7 +17,7 @@ import { Config } from '@backstage/config'; import { base64url, decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose'; import { readAccessRestrictionsFromConfig } from './helpers'; -import { AccessRestriptionsMap, TokenHandler } from './types'; +import { AccessRestrictionsMap, TokenHandler } from './types'; /** * Handles `type: legacy` access. @@ -29,7 +29,7 @@ export class LegacyTokenHandler implements TokenHandler { key: Uint8Array; result: { subject: string; - allAccessRestrictions?: AccessRestriptionsMap; + allAccessRestrictions?: AccessRestrictionsMap; }; }>(); @@ -52,7 +52,7 @@ export class LegacyTokenHandler implements TokenHandler { #doAdd( secret: string, subject: string, - allAccessRestrictions?: AccessRestriptionsMap, + allAccessRestrictions?: AccessRestrictionsMap, ) { if (!secret.match(/^\S+$/)) { throw new Error('Illegal secret, must be a valid base64 string'); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/static.ts b/packages/backend-defaults/src/entrypoints/auth/external/static.ts index b335b89e58..e003f0530b 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/static.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/static.ts @@ -16,7 +16,7 @@ import { Config } from '@backstage/config'; import { readAccessRestrictionsFromConfig } from './helpers'; -import { AccessRestriptionsMap, TokenHandler } from './types'; +import { AccessRestrictionsMap, TokenHandler } from './types'; const MIN_TOKEN_LENGTH = 8; @@ -30,7 +30,7 @@ export class StaticTokenHandler implements TokenHandler { string, { subject: string; - allAccessRestrictions?: AccessRestriptionsMap; + allAccessRestrictions?: AccessRestrictionsMap; } >(); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/types.ts b/packages/backend-defaults/src/entrypoints/auth/external/types.ts index e3cad90e17..6b882ef16b 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/types.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/types.ts @@ -17,17 +17,25 @@ import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; -export type AccessRestriptionsMap = Map< +/** + * @public + */ +export type AccessRestrictionsMap = Map< string, // plugin ID BackstagePrincipalAccessRestrictions >; +/** + * @public + * This interface is used to handle external tokens. + * It is used by the auth service to verify tokens and extract the subject. + */ export interface TokenHandler { add?(options: Config): TokenHandler; verifyToken(token: string): Promise< | { subject: string; - allAccessRestrictions?: AccessRestriptionsMap; + allAccessRestrictions?: AccessRestrictionsMap; } | undefined >; diff --git a/packages/backend-defaults/src/entrypoints/auth/index.ts b/packages/backend-defaults/src/entrypoints/auth/index.ts index 51fe982891..96db6c0535 100644 --- a/packages/backend-defaults/src/entrypoints/auth/index.ts +++ b/packages/backend-defaults/src/entrypoints/auth/index.ts @@ -17,9 +17,10 @@ export { authServiceFactory, pluginTokenHandlerDecoratorServiceRef, - externalTokenHandlerDecoratorServiceRef, + externalTokenHandlersServiceRef, } from './authServiceFactory'; -export type { ExternalTokenHandler } from './external/ExternalTokenHandler'; +export type { TokenHandler } from './external/types'; +export type { AccessRestrictionsMap } from './external/types'; export type { PluginTokenHandler } from './plugin/PluginTokenHandler'; From 410b81afd70627923e795a43269a2daec265a62d Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Fri, 25 Apr 2025 08:46:55 +0200 Subject: [PATCH 04/15] feat: use multiple Config factory Signed-off-by: Juan Pablo Garcia Ripa --- packages/backend-defaults/report-auth.api.md | 7 +- .../auth/authServiceFactory.test.ts | 38 ++--- .../entrypoints/auth/authServiceFactory.ts | 16 +- .../external/ExternalTokenHandler.test.ts | 100 +++++++++--- .../auth/external/ExternalTokenHandler.ts | 117 ++++++++++---- .../entrypoints/auth/external/jwks.test.ts | 45 +++--- .../src/entrypoints/auth/external/jwks.ts | 7 +- .../entrypoints/auth/external/legacy.test.ts | 143 ++++++++++-------- .../src/entrypoints/auth/external/legacy.ts | 20 +++ .../entrypoints/auth/external/static.test.ts | 129 ++++++++-------- .../src/entrypoints/auth/external/static.ts | 7 +- .../src/entrypoints/auth/external/types.ts | 2 - .../src/entrypoints/auth/index.ts | 3 +- 13 files changed, 392 insertions(+), 242 deletions(-) diff --git a/packages/backend-defaults/report-auth.api.md b/packages/backend-defaults/report-auth.api.md index 9b7a56cff5..bcc5c4a073 100644 --- a/packages/backend-defaults/report-auth.api.md +++ b/packages/backend-defaults/report-auth.api.md @@ -23,9 +23,10 @@ export const authServiceFactory: ServiceFactory< >; // @public -export const externalTokenHandlersServiceRef: ServiceRef< +export const externalTokenTypeHandlersRef: ServiceRef< { - [configKey: string]: (config: Config) => TokenHandler; + type: string; + factory: (config: Config[]) => TokenHandler; }, 'plugin', 'multiton' @@ -63,8 +64,6 @@ export const pluginTokenHandlerDecoratorServiceRef: ServiceRef< // @public export interface TokenHandler { - // (undocumented) - add?(options: Config): TokenHandler; // (undocumented) verifyToken(token: string): Promise< | { diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts index d6cc0023a8..b808973016 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts @@ -21,7 +21,6 @@ import { } from '@backstage/backend-test-utils'; import { authServiceFactory, - externalTokenHandlersServiceRef, pluginTokenHandlerDecoratorServiceRef, } from './authServiceFactory'; import { base64url, decodeJwt } from 'jose'; @@ -33,8 +32,7 @@ import { PluginTokenHandler } from './plugin/PluginTokenHandler'; import { createServiceFactory } from '@backstage/backend-plugin-api'; import { AccessRestrictionsMap, TokenHandler } from './external/types'; import { Config } from '@backstage/config'; -// import { ExternalTokenHandler } from './external/ExternalTokenHandler'; -// import { TokenHandler } from './external/types'; +import { externalTokenTypeHandlersRef } from './external/ExternalTokenHandler'; const server = setupServer(); @@ -504,10 +502,11 @@ describe('authServiceFactory', () => { }), ]; - const customHandler = new (class CustomHandler implements TokenHandler { - add(options: Config): TokenHandler { - customAddEntry(options); - return this; + class CustomHandler implements TokenHandler { + constructor(options: Config[]) { + for (const option of options) { + customAddEntry(option); + } } async verifyToken(token: string): Promise< | { @@ -521,16 +520,17 @@ describe('authServiceFactory', () => { subject: 'foo', }; } - })(); + } const customPluginTokenHandler = createServiceFactory({ - service: externalTokenHandlersServiceRef, + service: externalTokenTypeHandlersRef, deps: {}, async factory() { return { - custom: (options: Config) => { - customConfig(options); - return customHandler.add(options); + type: 'custom', + factory: (configs: Config[]) => { + customConfig(configs); + return new CustomHandler(configs); }, }; }, @@ -553,14 +553,16 @@ describe('authServiceFactory', () => { ); expect(customLogic).toHaveBeenCalledWith('custom-token'); expect(customConfig).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - options: expect.objectContaining({ - [`custom-config`]: 'custom-config', - foo: 'bar', + expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + options: expect.objectContaining({ + [`custom-config`]: 'custom-config', + foo: 'bar', + }), }), }), - }), + ]), ); }); }); diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts index ae8ad205b7..43a3c9da50 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts @@ -21,8 +21,8 @@ import { } from '@backstage/backend-plugin-api'; import { DefaultAuthService } from './DefaultAuthService'; import { - // DefaultExternalTokenHandler, ExternalTokenHandler, + externalTokenTypeHandlersRef, } from './external/ExternalTokenHandler'; import { DefaultPluginTokenHandler, @@ -30,8 +30,6 @@ import { } from './plugin/PluginTokenHandler'; import { createPluginKeySource } from './plugin/keys/createPluginKeySource'; import { UserTokenHandler } from './user/UserTokenHandler'; -import { TokenHandler } from './external/types'; -import { Config } from '@backstage/config'; /** * @public @@ -50,16 +48,6 @@ export const pluginTokenHandlerDecoratorServiceRef = createServiceRef< }, }), }); -/** - * @public - * This service is used to decorate the default plugin token handler with custom logic. - */ -export const externalTokenHandlersServiceRef = createServiceRef<{ - [configKey: string]: (config: Config) => TokenHandler; -}>({ - id: 'core.auth.externalTokenHandlers', - multiton: true, -}); /** * Handles token authentication and credentials management. @@ -79,7 +67,7 @@ export const authServiceFactory = createServiceFactory({ plugin: coreServices.pluginMetadata, database: coreServices.database, pluginTokenHandlerDecorator: pluginTokenHandlerDecoratorServiceRef, - externalTokenHandlers: externalTokenHandlersServiceRef, + externalTokenHandlers: externalTokenTypeHandlersRef, }, async factory({ config, diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.test.ts index 3567061f03..3348d84e93 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.test.ts @@ -85,12 +85,10 @@ describe('ExternalTokenHandler', () => { it('skips over inner handlers that do not match, and applies plugin restrictions', async () => { const handler1: TokenHandler = { - add: jest.fn(), verifyToken: jest.fn().mockResolvedValue(undefined), }; const handler2: TokenHandler = { - add: jest.fn(), verifyToken: jest.fn().mockResolvedValue({ subject: 'sub', allAccessRestrictions: new Map( @@ -225,10 +223,9 @@ describe('ExternalTokenHandler', () => { ); const customHandler: TokenHandler = { - add: jest.fn(), verifyToken: jest.fn(), }; - const customHandlerCreator = jest.fn(() => customHandler); + const customHandlerFactory = jest.fn(() => customHandler); const handler = ExternalTokenHandler.create({ ownPluginId: 'catalog', @@ -254,23 +251,30 @@ describe('ExternalTokenHandler', () => { }, }, }), - externalTokenHandlers: [{ ['internal-custom']: customHandlerCreator }], + externalTokenHandlers: [ + { + type: 'internal-custom', + factory: customHandlerFactory, + }, + ], }); - expect(customHandlerCreator).toHaveBeenCalledWith( - expect.objectContaining({ - data: { - type: 'internal-custom', - options: { - issuer: 'my-company', - subject: 'internal-subject', - audience: 'backstage', + expect(customHandlerFactory).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + data: { + type: 'internal-custom', + options: { + issuer: 'my-company', + subject: 'internal-subject', + audience: 'backstage', + }, + accessRestrictions: [ + { plugin: 'catalog', permission: 'catalog.entity.read' }, + ], }, - accessRestrictions: [ - { plugin: 'catalog', permission: 'catalog.entity.read' }, - ], - }, - }), + }), + ]), ); const customToken = await factory.issueToken({ @@ -310,9 +314,10 @@ describe('ExternalTokenHandler', () => { }); expect(createHandler).toThrowErrorMatchingInlineSnapshot( - `"Unknown type 'internal-custom' in backend.auth.externalAccess, expected one of 'static', 'legacy', 'jwks'"`, + `"Unknown type(s) 'internal-custom' in backend.auth.externalAccess, expected one of 'static', 'legacy', 'jwks'"`, ); }); + it('should show valid custom types in errors', async () => { const createHandler = () => ExternalTokenHandler.create({ @@ -341,8 +346,8 @@ describe('ExternalTokenHandler', () => { }), externalTokenHandlers: [ { - ['internal-custom']: () => ({ - add: jest.fn(), + type: 'internal-custom', + factory: () => ({ verifyToken: jest.fn(), }), }, @@ -350,7 +355,58 @@ describe('ExternalTokenHandler', () => { }); expect(createHandler).toThrowErrorMatchingInlineSnapshot( - `"Unknown type 'internal-custom-invalid' in backend.auth.externalAccess, expected one of 'static', 'legacy', 'jwks', 'internal-custom'"`, + `"Unknown type(s) 'internal-custom-invalid' in backend.auth.externalAccess, expected one of 'static', 'legacy', 'jwks', 'internal-custom'"`, + ); + }); + it('should show all invalid config types', async () => { + const createHandler = () => + ExternalTokenHandler.create({ + ownPluginId: 'catalog', + logger: mockServices.logger.mock(), + config: mockServices.rootConfig({ + data: { + backend: { + auth: { + externalAccess: [ + { + type: 'internal-custom-invalid', + options: { + issuer: 'my-company', + subject: 'internal-subject', + audience: 'backstage', + }, + accessRestrictions: [ + { plugin: 'catalog', permission: 'catalog.entity.read' }, + ], + }, + { + type: 'internal-custom-invalid-2', + options: { + issuer: 'my-company', + subject: 'internal-subject', + audience: 'backstage', + }, + accessRestrictions: [ + { plugin: 'catalog', permission: 'catalog.entity.read' }, + ], + }, + ], + }, + }, + }, + }), + externalTokenHandlers: [ + { + type: 'internal-custom', + factory: () => ({ + verifyToken: jest.fn(), + }), + }, + ], + }); + + expect(createHandler).toThrowErrorMatchingInlineSnapshot( + `"Unknown type(s) 'internal-custom-invalid, internal-custom-invalid-2' in backend.auth.externalAccess, expected one of 'static', 'legacy', 'jwks', 'internal-custom'"`, ); }); }); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.ts index a4c55810c3..cc93b7b182 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.ts @@ -16,20 +16,70 @@ import { BackstagePrincipalAccessRestrictions, + createServiceRef, LoggerService, RootConfigService, } from '@backstage/backend-plugin-api'; import { NotAllowedError } from '@backstage/errors'; -import { LegacyTokenHandler } from './legacy'; +import { LegacyConfigWrapper, LegacyTokenHandler } from './legacy'; import { StaticTokenHandler } from './static'; import { JWKSHandler } from './jwks'; import { TokenHandler } from './types'; import { Config } from '@backstage/config'; +import { groupBy } from 'lodash'; const NEW_CONFIG_KEY = 'backend.auth.externalAccess'; const OLD_CONFIG_KEY = 'backend.auth.keys'; let loggedDeprecationWarning = false; +/** + * @public + * This service is used to decorate the default plugin token handler with custom logic. + */ +export const externalTokenTypeHandlersRef = createServiceRef<{ + type: string; + factory: (config: Config[]) => TokenHandler; +}>({ + id: 'core.auth.externalTokenHandlers', + multiton: true, + // defaultFactory // :pepe-think: seems like is not possible to use defaultFactory with multiton +}); + +type TokenTypeHandler = { + type: string; + /** + * A factory function that takes all token configuration for a given type + * and returns a TokenHandler or an array of TokenHandlers. + */ + factory: (config: Config[]) => TokenHandler | TokenHandler[]; +}; +type LegacyTokenTypeHandler = { + type: 'legacy'; + /** + * A factory function that takes all token configuration for a given type + * and returns a TokenHandler or an array of TokenHandlers. + */ + factory: ( + config: (Config | LegacyConfigWrapper)[], + ) => TokenHandler | TokenHandler[]; +}; + +const defaultHandlers: (TokenTypeHandler | LegacyTokenTypeHandler)[] = [ + { + type: 'static', + factory: configs => new StaticTokenHandler(configs), + }, + { + type: 'legacy', + factory: (configs: (Config | { legacy: true; config: Config })[]) => + new LegacyTokenHandler(configs), + }, + { + type: 'jwks', + factory: configs => new JWKSHandler(configs), + }, +]; + /** * Handles all types of external caller token types (i.e. not Backstage user * tokens, nor Backstage backend plugin tokens). @@ -41,9 +91,7 @@ export class ExternalTokenHandler { ownPluginId: string; config: RootConfigService; logger: LoggerService; - externalTokenHandlers?: { - [key: string]: (config: Config) => TokenHandler; - }[]; + externalTokenHandlers?: TokenTypeHandler[]; }): ExternalTokenHandler { const { ownPluginId, @@ -52,36 +100,18 @@ export class ExternalTokenHandler { externalTokenHandlers: customHandlers, } = options; - const staticHandler = new StaticTokenHandler(); - const legacyHandler = new LegacyTokenHandler(); - const jwksHandler = new JWKSHandler(); - const handlers: Record TokenHandler> = { - static: (handlerConfig: Config) => staticHandler.add(handlerConfig), - legacy: (handlerConfig: Config) => legacyHandler.add(handlerConfig), - jwks: (handlerConfig: Config) => jwksHandler.add(handlerConfig), - ...Object.assign({}, ...(customHandlers ?? [])), - }; - const configuredHandlers = []; + const handlersTypes = [...defaultHandlers, ...(customHandlers ?? [])]; - // Load the new-style handlers const handlerConfigs = config.getOptionalConfigArray(NEW_CONFIG_KEY) ?? []; - for (const handlerConfig of handlerConfigs) { - const type = handlerConfig.getString('type'); - const handler = handlers[type]; - if (!handler) { - const valid = Object.keys(handlers) - .map(k => `'${k}'`) - .join(', '); - throw new Error( - `Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`, - ); - } - configuredHandlers.push(handler(handlerConfig)); - // handler.add(handlerConfig); - } + const handlerConfigByType: Record & { + legacy?: (Config | LegacyConfigWrapper)[]; + } = groupBy(handlerConfigs, (handlerConfig: Config) => + handlerConfig.getString('type'), + ); // Load the old keys too const legacyConfigs = config.getOptionalConfigArray(OLD_CONFIG_KEY) ?? []; + if (legacyConfigs.length && !loggedDeprecationWarning) { loggedDeprecationWarning = true; logger.warn( @@ -89,10 +119,35 @@ export class ExternalTokenHandler { ); } for (const handlerConfig of legacyConfigs) { - legacyHandler.addOld(handlerConfig); + handlerConfigByType.legacy ??= []; + handlerConfigByType.legacy.push({ legacy: true, config: handlerConfig }); } - return new ExternalTokenHandler(ownPluginId, configuredHandlers); + const invalidTypes = Object.keys(handlerConfigByType).filter( + type => !handlersTypes.some(handler => handler.type === type), + ); + + if (invalidTypes.length > 0) { + const valid = handlersTypes + .map(handler => `'${handler.type}'`) + .join(', '); + throw new Error( + `Unknown type(s) '${invalidTypes.join( + ', ', + )}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`, + ); + } + + const handlers = handlersTypes.flatMap(handler => { + const configs = handlerConfigByType[handler.type] ?? []; + const handlerInstances = handler.factory(configs); + if (Array.isArray(handlerInstances)) { + return handlerInstances; + } + return [handlerInstances]; + }); + + return new ExternalTokenHandler(ownPluginId, handlers); } constructor( diff --git a/packages/backend-defaults/src/entrypoints/auth/external/jwks.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/jwks.test.ts index 321e1823aa..eefbce5093 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/jwks.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/jwks.test.ts @@ -108,9 +108,7 @@ describe('JWKSHandler', () => { audience: 'backstage', }, }; - const jwksHandler = new JWKSHandler(); - - jwksHandler.add(new ConfigReader(validEntry)); + const jwksHandler = new JWKSHandler([new ConfigReader(validEntry)]); const token = await factory.issueToken({ claims: { sub: mockSubject }, @@ -139,10 +137,10 @@ describe('JWKSHandler', () => { audience: ['multiple-audiences', 'backstage'], }, }; - const jwksHandler = new JWKSHandler(); - - jwksHandler.add(new ConfigReader(invalidEntry)); - jwksHandler.add(new ConfigReader(validEntry)); + const jwksHandler = new JWKSHandler([ + new ConfigReader(invalidEntry), + new ConfigReader(validEntry), + ]); const token = await factory.issueToken({ claims: { sub: mockSubject }, @@ -169,10 +167,10 @@ describe('JWKSHandler', () => { audience: 'wrong', }, }; - const jwksHandler = new JWKSHandler(); - - jwksHandler.add(new ConfigReader(invalidEntry1)); - jwksHandler.add(new ConfigReader(invalidEntry2)); + const jwksHandler = new JWKSHandler([ + new ConfigReader(invalidEntry1), + new ConfigReader(invalidEntry2), + ]); const token = await factory.issueToken({ claims: { sub: mockSubject }, @@ -184,30 +182,26 @@ describe('JWKSHandler', () => { }); it('rejects bad config', () => { - const jwksHandler = new JWKSHandler(); - expect(() => { - jwksHandler.add( + return new JWKSHandler([ new ConfigReader({ - options: { - url: 'https://exampl e.com/jwks', - }, + options: { url: 'https://exampl e.com/jwks' }, }), - ); + ]); }).toThrow('Illegal JWKS URL, must be a set of non-space characters'); expect(() => { - jwksHandler.add( + return new JWKSHandler([ new ConfigReader({ options: { url: 'https://example.com/jwks\n', }, }), - ); + ]); }).toThrow('Illegal JWKS URL, must be a set of non-space characters'); }); it('gracefully handles no added tokens', async () => { - const handler = new JWKSHandler(); + const handler = new JWKSHandler([]); await expect(handler.verifyToken('ghi')).resolves.toBeUndefined(); }); @@ -221,9 +215,7 @@ describe('JWKSHandler', () => { subjectPrefix: 'custom-prefix', }, }; - const jwksHandler = new JWKSHandler(); - - jwksHandler.add(new ConfigReader(validEntry)); + const jwksHandler = new JWKSHandler([new ConfigReader(validEntry)]); const token = await factory.issueToken({ claims: { sub: mockSubject }, @@ -237,15 +229,14 @@ describe('JWKSHandler', () => { }); it('carries over access restrictions', async () => { - const jwksHandler = new JWKSHandler(); - jwksHandler.add( + const jwksHandler = new JWKSHandler([ new ConfigReader({ options: { url: `${mockBaseUrl}/.well-known/jwks.json`, }, accessRestrictions: [{ plugin: 'scaffolder', permission: 'do.it' }], }), - ); + ]); const token = await factory.issueToken({ claims: { sub: mockSubject } }); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts b/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts index 49167bcd35..5f7fcb4127 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts @@ -38,7 +38,12 @@ export class JWKSHandler implements TokenHandler { allAccessRestrictions?: AccessRestrictionsMap; }> = []; - add(config: Config) { + constructor(configs: Config[]) { + for (const config of configs) { + this.add(config); + } + } + private add(config: Config) { if (!config.getString('options.url').match(/^\S+$/)) { throw new Error( 'Illegal JWKS URL, must be a set of non-space characters', diff --git a/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts index c674e46ec9..4892d0a21a 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts @@ -21,7 +21,6 @@ import { DateTime } from 'luxon'; import { LegacyTokenHandler } from './legacy'; describe('LegacyTokenHandler', () => { - const tokenHandler = new LegacyTokenHandler(); const key1 = randomBytes(24); const key2 = randomBytes(24); const key3 = randomBytes(24); @@ -36,7 +35,7 @@ describe('LegacyTokenHandler', () => { }), ); - tokenHandler.add( + const configs = [ new ConfigReader({ options: { secret: key1.toString('base64'), @@ -44,8 +43,7 @@ describe('LegacyTokenHandler', () => { }, accessRestrictions: [{ plugin: 'scaffolder' }], }), - ); - tokenHandler.add( + new ConfigReader({ options: { secret: key2.toString('base64'), @@ -55,12 +53,14 @@ describe('LegacyTokenHandler', () => { { plugin: 'catalog', permission: 'catalog.entity.read' }, ], }), - ); - tokenHandler.addOld( - new ConfigReader({ - secret: key3.toString('base64'), - }), - ); + { + legacy: true, + config: new ConfigReader({ + secret: key3.toString('base64'), + }), + }, + ]; + const tokenHandler = new LegacyTokenHandler(configs); it('should verify valid tokens', async () => { const token1 = await new SignJWT({ @@ -163,94 +163,113 @@ describe('LegacyTokenHandler', () => { }); it('rejects bad config', () => { - const handler = new LegacyTokenHandler(); + const handler = new LegacyTokenHandler([]); // new style add, bad secrets - expect(() => - handler.add( - new ConfigReader({ options: { _missingsecret: true, subject: 'ok' } }), - ), + expect( + () => + new LegacyTokenHandler([ + new ConfigReader({ + options: { _missingsecret: true, subject: 'ok' }, + }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Missing required config value at 'options.secret' in 'mock-config'"`, ); - expect(() => - handler.add(new ConfigReader({ options: { secret: '', subject: 'ok' } })), + expect( + () => + new LegacyTokenHandler([ + new ConfigReader({ options: { secret: '', subject: 'ok' } }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Invalid type in config for key 'options.secret' in 'mock-config', got empty-string, wanted string"`, ); - expect(() => - handler.add( - new ConfigReader({ options: { secret: 'has spaces', subject: 'ok' } }), - ), + expect( + () => + new LegacyTokenHandler([ + new ConfigReader({ + options: { secret: 'has spaces', subject: 'ok' }, + }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Illegal secret, must be a valid base64 string"`, ); - expect(() => - handler.add( - new ConfigReader({ - options: { secret: 'hasnewline\n', subject: 'ok' }, - }), - ), + expect( + () => + new LegacyTokenHandler([ + new ConfigReader({ + options: { secret: 'hasnewline\n', subject: 'ok' }, + }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Illegal secret, must be a valid base64 string"`, ); - expect(() => - handler.add(new ConfigReader({ options: { secret: 3, subject: 'ok' } })), + expect( + () => + new LegacyTokenHandler([ + new ConfigReader({ options: { secret: 3, subject: 'ok' } }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Invalid type in config for key 'options.secret' in 'mock-config', got number, wanted string"`, ); // new style add, bad subjects - expect(() => - handler.add( - new ConfigReader({ - options: { secret: 'b2s=', _missingsubject: true }, - }), - ), + expect( + () => + new LegacyTokenHandler([ + new ConfigReader({ + options: { secret: 'b2s=', _missingsubject: true }, + }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Missing required config value at 'options.subject' in 'mock-config'"`, ); - expect(() => - handler.add( - new ConfigReader({ options: { secret: 'b2s=', subject: '' } }), - ), + expect( + () => + new LegacyTokenHandler([ + new ConfigReader({ options: { secret: 'b2s=', subject: '' } }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Invalid type in config for key 'options.subject' in 'mock-config', got empty-string, wanted string"`, ); - expect(() => - handler.add( - new ConfigReader({ - options: { secret: 'b2s=', subject: 'has spaces' }, - }), - ), + expect( + () => + new LegacyTokenHandler([ + new ConfigReader({ + options: { secret: 'b2s=', subject: 'has spaces' }, + }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Illegal subject, must be a set of non-space characters"`, ); - expect(() => - handler.add( - new ConfigReader({ - options: { secret: 'b2s=', subject: 'hasnewline\n' }, - }), - ), + expect( + () => + new LegacyTokenHandler([ + new ConfigReader({ + options: { secret: 'b2s=', subject: 'hasnewline\n' }, + }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Illegal subject, must be a set of non-space characters"`, ); - expect(() => - handler.add( - new ConfigReader({ options: { secret: 'b2s=', subject: 3 } }), - ), + expect( + () => + new LegacyTokenHandler([ + new ConfigReader({ options: { secret: 'b2s=', subject: 3 } }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Invalid type in config for key 'options.subject' in 'mock-config', got number, wanted string"`, ); // new style add, bad access restrictions - expect(() => - handler.add( - new ConfigReader({ - options: { secret: 'b2s=', subject: 'subject' }, - accessRestrictions: [{ plugin: ['a'] }], - }), - ), + expect( + () => + new LegacyTokenHandler([ + new ConfigReader({ + options: { secret: 'b2s=', subject: 'subject' }, + accessRestrictions: [{ plugin: ['a'] }], + }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Invalid type in config for key 'accessRestrictions[0].plugin' in 'mock-config', got array, wanted string"`, ); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts b/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts index 09d759a09e..8be0de19e4 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts @@ -19,6 +19,10 @@ import { base64url, decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose'; import { readAccessRestrictionsFromConfig } from './helpers'; import { AccessRestrictionsMap, TokenHandler } from './types'; +export type LegacyConfigWrapper = { + legacy: boolean; + config: Config; +}; /** * Handles `type: legacy` access. * @@ -33,6 +37,16 @@ export class LegacyTokenHandler implements TokenHandler { }; }>(); + constructor(configs: (Config | LegacyConfigWrapper)[]) { + for (const config of configs) { + if (isLegacy(config)) { + this.addOld(config.config); + continue; + } + this.add(config); + } + } + add(config: Config) { const allAccessRestrictions = readAccessRestrictionsFromConfig(config); this.#doAdd( @@ -119,3 +133,9 @@ export class LegacyTokenHandler implements TokenHandler { return undefined; } } + +function isLegacy( + config: Config | LegacyConfigWrapper, +): config is LegacyConfigWrapper { + return (config as LegacyConfigWrapper).legacy === true; +} diff --git a/packages/backend-defaults/src/entrypoints/auth/external/static.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/static.test.ts index a5945daba9..cf1c1a46ac 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/static.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/static.test.ts @@ -19,21 +19,19 @@ import { StaticTokenHandler } from './static'; describe('StaticTokenHandler', () => { it('accepts any of the added list of tokens', async () => { - const handler = new StaticTokenHandler(); - handler.add( + const configs = [ new ConfigReader({ options: { token: 'abcabcabc', subject: 'one' }, accessRestrictions: [{ plugin: 'scaffolder' }], }), - ); - handler.add( new ConfigReader({ options: { token: 'defdefdef', subject: 'two' }, accessRestrictions: [ { plugin: 'catalog', permission: 'catalog.entity.read' }, ], }), - ); + ]; + const handler = new StaticTokenHandler(configs); const accessRestrictionsOne = new Map(Object.entries({ scaffolder: {} })); const accessRestrictionsTwo = new Map( Object.entries({ @@ -55,95 +53,108 @@ describe('StaticTokenHandler', () => { }); it('gracefully handles no added tokens', async () => { - const handler = new StaticTokenHandler(); + const handler = new StaticTokenHandler([]); await expect(handler.verifyToken('ghi')).resolves.toBeUndefined(); }); it('rejects bad config', () => { - const handler = new StaticTokenHandler(); - - expect(() => - handler.add( - new ConfigReader({ options: { _missingtoken: true, subject: 'ok' } }), - ), + expect( + () => + new StaticTokenHandler([ + new ConfigReader({ options: { _missingtoken: true, subject: 'ok' } }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Missing required config value at 'options.token' in 'mock-config'"`, ); - expect(() => - handler.add(new ConfigReader({ options: { token: '', subject: 'ok' } })), + expect( + () => + new StaticTokenHandler([ + new ConfigReader({ options: { token: '', subject: 'ok' } }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Invalid type in config for key 'options.token' in 'mock-config', got empty-string, wanted string"`, ); - expect(() => - handler.add( - new ConfigReader({ options: { token: 'has spaces', subject: 'ok' } }), - ), + expect( + () => + new StaticTokenHandler([ + new ConfigReader({ options: { token: 'has spaces', subject: 'ok' } }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Illegal token, must be a set of non-space characters"`, ); - expect(() => - handler.add( - new ConfigReader({ - options: { - token: 'hasnewlinebutislongenough\n', - subject: 'ok', - }, - }), - ), + expect( + () => + new StaticTokenHandler([ + new ConfigReader({ + options: { + token: 'hasnewlinebutislongenough\n', + subject: 'ok', + }, + }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Illegal token, must be a set of non-space characters"`, ); - expect(() => - handler.add( - new ConfigReader({ options: { token: 'short', subject: 'ok' } }), - ), + expect( + () => + new StaticTokenHandler([ + new ConfigReader({ options: { token: 'short', subject: 'ok' } }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Illegal token, must be at least 8 characters length"`, ); - expect(() => - handler.add(new ConfigReader({ options: { token: 3, subject: 'ok' } })), + expect( + () => + new StaticTokenHandler([ + new ConfigReader({ options: { token: 3, subject: 'ok' } }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Invalid type in config for key 'options.token' in 'mock-config', got number, wanted string"`, ); - expect(() => - handler.add( - new ConfigReader({ - options: { token: 'validtoken', _missingsubject: true }, - }), - ), + expect( + () => + new StaticTokenHandler([ + new ConfigReader({ + options: { token: 'validtoken', _missingsubject: true }, + }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Missing required config value at 'options.subject' in 'mock-config'"`, ); - expect(() => - handler.add( - new ConfigReader({ options: { token: 'validtoken', subject: '' } }), - ), + expect( + () => + new StaticTokenHandler([ + new ConfigReader({ options: { token: 'validtoken', subject: '' } }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Invalid type in config for key 'options.subject' in 'mock-config', got empty-string, wanted string"`, ); - expect(() => - handler.add( - new ConfigReader({ - options: { token: 'validtoken', subject: 'has spaces' }, - }), - ), + expect( + () => + new StaticTokenHandler([ + new ConfigReader({ + options: { token: 'validtoken', subject: 'has spaces' }, + }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Illegal subject, must be a set of non-space characters"`, ); - expect(() => - handler.add( - new ConfigReader({ - options: { token: 'validtoken', subject: 'hasnewline\n' }, - }), - ), + expect( + () => + new StaticTokenHandler([ + new ConfigReader({ + options: { token: 'validtoken', subject: 'hasnewline\n' }, + }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Illegal subject, must be a set of non-space characters"`, ); - expect(() => - handler.add( - new ConfigReader({ options: { token: 'validtoken', subject: 3 } }), - ), + expect( + () => + new StaticTokenHandler([ + new ConfigReader({ options: { token: 'validtoken', subject: 3 } }), + ]), ).toThrowErrorMatchingInlineSnapshot( `"Invalid type in config for key 'options.subject' in 'mock-config', got number, wanted string"`, ); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/static.ts b/packages/backend-defaults/src/entrypoints/auth/external/static.ts index e003f0530b..7764569aca 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/static.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/static.ts @@ -34,7 +34,12 @@ export class StaticTokenHandler implements TokenHandler { } >(); - add(config: Config) { + constructor(configs: Config[]) { + for (const config of configs) { + this.add(config); + } + } + private add(config: Config) { const token = config.getString('options.token'); const subject = config.getString('options.subject'); const allAccessRestrictions = readAccessRestrictionsFromConfig(config); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/types.ts b/packages/backend-defaults/src/entrypoints/auth/external/types.ts index 6b882ef16b..c3d45fd0d7 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/types.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/types.ts @@ -15,7 +15,6 @@ */ import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api'; -import { Config } from '@backstage/config'; /** * @public @@ -31,7 +30,6 @@ export type AccessRestrictionsMap = Map< * It is used by the auth service to verify tokens and extract the subject. */ export interface TokenHandler { - add?(options: Config): TokenHandler; verifyToken(token: string): Promise< | { subject: string; diff --git a/packages/backend-defaults/src/entrypoints/auth/index.ts b/packages/backend-defaults/src/entrypoints/auth/index.ts index 96db6c0535..1b1a29a247 100644 --- a/packages/backend-defaults/src/entrypoints/auth/index.ts +++ b/packages/backend-defaults/src/entrypoints/auth/index.ts @@ -17,9 +17,10 @@ export { authServiceFactory, pluginTokenHandlerDecoratorServiceRef, - externalTokenHandlersServiceRef, } from './authServiceFactory'; +export { externalTokenTypeHandlersRef } from './external/ExternalTokenHandler'; + export type { TokenHandler } from './external/types'; export type { AccessRestrictionsMap } from './external/types'; From 65d382e272f66896623a5d611caf77d9fe772b52 Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Thu, 15 May 2025 22:58:35 +0200 Subject: [PATCH 05/15] fix: update docs with new approach and export types Signed-off-by: Juan Pablo Garcia Ripa --- docs/auth/service-to-service-auth.md | 35 ++++++++++++++---- packages/backend-defaults/report-auth.api.md | 12 ++++-- .../auth/external/ExternalTokenHandler.ts | 37 ++++++++----------- .../src/entrypoints/auth/external/types.ts | 15 ++++++++ .../src/entrypoints/auth/index.ts | 2 +- 5 files changed, 67 insertions(+), 34 deletions(-) diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index 8e8ef6a183..4d8829d91d 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -414,7 +414,7 @@ Each entry has one or more of the following fields: ## Adding custom or logic for validation and issuing of tokens -The `pluginTokenHandlerDecoratorServiceRef` and `externalTokenHandlersServiceRef` can be used to extend the existing token handler without having to re-implement the entire `AuthService` implementation. +The `pluginTokenHandlerDecoratorServiceRef` and `externalTokenTypeHandlersRef` can be used to extend the existing token handler without having to re-implement the entire `AuthService` implementation. This is particularly useful when you want to add additional logic to the handler, such as logging or metrics or custom token validation. ### PluginTokenHandler decoration @@ -446,26 +446,47 @@ const decoratedPluginTokenHandler = createServiceFactory({ ### ExternalTokenHandler decoration -The `externalTokenHandlersServiceRef` can be used to add custom external token handlers to the default implementation. +The `externalTokenTypeHandlersRef` can be used to add custom external token handlers to the default implementation. -The returned object should be a map of token custom types and their handler factories. The object keys will be matched to the configured `externalAccess` type in the app-config, calling the factory function with the config object for that type. Custom token handlers should implement the `TokenHandler` interface, which provides methods for verifying tokens. +The returned object from the factory function must have a `type` property which is used to identify the handler. The `factory` method is called with an array of `Config` objects, for the given type. The factory method can return a single handler or an array of handlers. The handlers are then used to handle the token for the given type. -For example, to add a custom token handler for a type called 'custom': +For example, if we whant to add a custom external token handler for the `custom` type: + +our config would look like this: + +```yaml title="in e.g. app-config.production.yaml" +backend: + auth: + externalAccess: + - type: custom + options: + customOptions: additional-value + accessRestrictions: + - plugin: events + - type: custom + options: + customOptions: another-value + accessRestrictions: + - plugin: events +``` + +And we can implement the custom token handler like this: ```ts import { TokenHandler, - externalTokenHandlersServiceRef, + externalTokenTypeHandlersRef, } from '@backstage/backend-defaults/auth'; import { Config } from '@backstage/config'; import { createServiceFactory } from '@backstage/backend-plugin-api'; const customExternalTokenHandlers = createServiceFactory({ - service: externalTokenHandlersServiceRef, + service: externalTokenTypeHandlersRef, deps: {}, async factory() { return { - custom: (config: Config) => new CustomTokenHandler(config); + type: 'custom', + factory: (configs: Config[]) => new CustomTokenHandler(configs), // can return a simple handler or an array of handlers }; }, }); diff --git a/packages/backend-defaults/report-auth.api.md b/packages/backend-defaults/report-auth.api.md index bcc5c4a073..2a80a59d2d 100644 --- a/packages/backend-defaults/report-auth.api.md +++ b/packages/backend-defaults/report-auth.api.md @@ -24,10 +24,7 @@ export const authServiceFactory: ServiceFactory< // @public export const externalTokenTypeHandlersRef: ServiceRef< - { - type: string; - factory: (config: Config[]) => TokenHandler; - }, + TokenTypeHandler, 'plugin', 'multiton' >; @@ -74,5 +71,12 @@ export interface TokenHandler { >; } +// @public +export interface TokenTypeHandler { + factory: (configs: Config[]) => TokenHandler | TokenHandler[]; + // (undocumented) + type: string; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.ts index cc93b7b182..c3fab70026 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.ts @@ -27,32 +27,12 @@ import { JWKSHandler } from './jwks'; import { TokenHandler } from './types'; import { Config } from '@backstage/config'; import { groupBy } from 'lodash'; +import { TokenTypeHandler } from './types'; const NEW_CONFIG_KEY = 'backend.auth.externalAccess'; const OLD_CONFIG_KEY = 'backend.auth.keys'; let loggedDeprecationWarning = false; -/** - * @public - * This service is used to decorate the default plugin token handler with custom logic. - */ -export const externalTokenTypeHandlersRef = createServiceRef<{ - type: string; - factory: (config: Config[]) => TokenHandler; -}>({ - id: 'core.auth.externalTokenHandlers', - multiton: true, - // defaultFactory // :pepe-think: seems like is not possible to use defaultFactory with multiton -}); - -type TokenTypeHandler = { - type: string; - /** - * A factory function that takes all token configuration for a given type - * and returns a TokenHandler or an array of TokenHandlers. - */ - factory: (config: Config[]) => TokenHandler | TokenHandler[]; -}; type LegacyTokenTypeHandler = { type: 'legacy'; /** @@ -60,10 +40,23 @@ type LegacyTokenTypeHandler = { * and returns a TokenHandler or an array of TokenHandlers. */ factory: ( - config: (Config | LegacyConfigWrapper)[], + configs: ( + | import('@backstage/config').Config + | { legacy: true; config: import('@backstage/config').Config } + )[], ) => TokenHandler | TokenHandler[]; }; +/** + * @public + * This service is used to decorate the default plugin token handler with custom logic. + */ +export const externalTokenTypeHandlersRef = createServiceRef({ + id: 'core.auth.externalTokenHandlers', + multiton: true, + // defaultFactory // :pepe-think: seems like is not possible to use defaultFactory with multiton +}); + const defaultHandlers: (TokenTypeHandler | LegacyTokenTypeHandler)[] = [ { type: 'static', diff --git a/packages/backend-defaults/src/entrypoints/auth/external/types.ts b/packages/backend-defaults/src/entrypoints/auth/external/types.ts index c3d45fd0d7..5e26b66da5 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/types.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/types.ts @@ -38,3 +38,18 @@ export interface TokenHandler { | undefined >; } + +/** + * @public + * This interface is used to handle external tokens. + */ +export interface TokenTypeHandler { + type: string; + /** + * A factory function that takes all token configuration for a given type + * and returns a TokenHandler or an array of TokenHandlers. + */ + factory: ( + configs: import('@backstage/config').Config[], + ) => TokenHandler | TokenHandler[]; +} diff --git a/packages/backend-defaults/src/entrypoints/auth/index.ts b/packages/backend-defaults/src/entrypoints/auth/index.ts index 1b1a29a247..335fc2f62c 100644 --- a/packages/backend-defaults/src/entrypoints/auth/index.ts +++ b/packages/backend-defaults/src/entrypoints/auth/index.ts @@ -21,7 +21,7 @@ export { export { externalTokenTypeHandlersRef } from './external/ExternalTokenHandler'; -export type { TokenHandler } from './external/types'; +export type { TokenHandler, TokenTypeHandler } from './external/types'; export type { AccessRestrictionsMap } from './external/types'; export type { PluginTokenHandler } from './plugin/PluginTokenHandler'; From 79336700729da563bbdf6798a602a3c42e1609ad Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Wed, 9 Jul 2025 22:41:36 +0200 Subject: [PATCH 06/15] fix typos in docs Signed-off-by: Juan Pablo Garcia Ripa --- docs/auth/service-to-service-auth.md | 4 ++-- docs/backend-system/architecture/03-services.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index 4d8829d91d..322edbe6b6 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -444,13 +444,13 @@ const decoratedPluginTokenHandler = createServiceFactory({ }); ``` -### ExternalTokenHandler decoration +### Custom ExternalTokenHandler The `externalTokenTypeHandlersRef` can be used to add custom external token handlers to the default implementation. The returned object from the factory function must have a `type` property which is used to identify the handler. The `factory` method is called with an array of `Config` objects, for the given type. The factory method can return a single handler or an array of handlers. The handlers are then used to handle the token for the given type. -For example, if we whant to add a custom external token handler for the `custom` type: +For example, if we want to add a custom external token handler for the `custom` type: our config would look like this: diff --git a/docs/backend-system/architecture/03-services.md b/docs/backend-system/architecture/03-services.md index 3b168b81e3..f6722496e5 100644 --- a/docs/backend-system/architecture/03-services.md +++ b/docs/backend-system/architecture/03-services.md @@ -225,7 +225,7 @@ This allows you to provide more advanced options for the service implementation ## Multiton -By default the service reference will point to a singleton instance of the service. This meand if a new service factory uses this reference will override the previous one. This is the most common use-case, but in some cases you may want to have multiple instances of the same service. +By default the service reference will point to a singleton instance of the service. This mean if a new service factory uses this reference will override the previous one. This is the most common use-case, but in some cases you may want to have multiple instances of the same service. For some services, is desirable to extend the functionality instead of overriding it. For example, some services could have many handler to address a specific event, and you may want to add a new handler instead of overriding the previous one. In this case, you can use the `multiton` option when creating the service reference: ```ts @@ -242,7 +242,7 @@ export const fooServiceRef = createServiceRef({ }); ``` -When adding this serviceRef ad dependency to a factory, the factory will receive an array of instances instead of a single instance: +When adding this `serviceRef` as a dependency to a factory, the factory will receive an array of instances instead of a single instance: ```ts deps: {fooServices: fooServiceRef}, From 445baebdedc78a8983853c13f55f56766e09df23 Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Sat, 6 Sep 2025 19:02:05 +0200 Subject: [PATCH 07/15] refactor: rename ExternalTokenHandler to ExternalAuthTokenHandler Signed-off-by: Juan Pablo Garcia Ripa --- ...ernalTokenHandler.test.ts => ExternalAuthTokenHandler.test.ts} | 0 .../{ExternalTokenHandler.ts => ExternalAuthTokenHandler.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/backend-defaults/src/entrypoints/auth/external/{ExternalTokenHandler.test.ts => ExternalAuthTokenHandler.test.ts} (100%) rename packages/backend-defaults/src/entrypoints/auth/external/{ExternalTokenHandler.ts => ExternalAuthTokenHandler.ts} (100%) diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts similarity index 100% rename from packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.test.ts rename to packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts similarity index 100% rename from packages/backend-defaults/src/entrypoints/auth/external/ExternalTokenHandler.ts rename to packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts From 95396a44f9b6ae7c12f2886751a4ec1049993e3b Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Sat, 6 Sep 2025 19:03:36 +0200 Subject: [PATCH 08/15] feat: simplify external token handler API - Remove accessRestrictions from individual handlers (now managed by framework) - Change API to evaluate configs individually for better performance Signed-off-by: Juan Pablo Garcia Ripa --- .changeset/thirty-rules-press.md | 21 ++ docs/auth/service-to-service-auth.md | 100 +++++- packages/backend-defaults/report-auth.api.md | 2 +- .../entrypoints/auth/DefaultAuthService.ts | 4 +- .../auth/authServiceFactory.test.ts | 73 ++-- .../entrypoints/auth/authServiceFactory.ts | 6 +- .../external/ExternalAuthTokenHandler.test.ts | 174 ++++------ .../auth/external/ExternalAuthTokenHandler.ts | 133 +++----- .../src/entrypoints/auth/external/helpers.ts | 8 +- .../entrypoints/auth/external/jwks.test.ts | 131 ++------ .../src/entrypoints/auth/external/jwks.ts | 109 +++--- .../entrypoints/auth/external/legacy.test.ts | 314 ++++++++---------- .../src/entrypoints/auth/external/legacy.ts | 174 ++++------ .../entrypoints/auth/external/static.test.ts | 196 ++++++----- .../src/entrypoints/auth/external/static.ts | 103 ++++-- .../src/entrypoints/auth/external/types.ts | 42 ++- .../src/entrypoints/auth/index.ts | 6 +- 17 files changed, 728 insertions(+), 868 deletions(-) diff --git a/.changeset/thirty-rules-press.md b/.changeset/thirty-rules-press.md index d9968317cb..760780eb84 100644 --- a/.changeset/thirty-rules-press.md +++ b/.changeset/thirty-rules-press.md @@ -3,3 +3,24 @@ --- Add a new `externalTokenHandlersServiceRef` to allow custom external token validations + +BREAKING CHANGE: The `backend.auth.keys` config has been removed. Please migrate to the new `backend.auth.externalAccess` config as described in the documentation: https://backstage.io/docs/auth/service-to-service-auth + +**Migration Example:** + +```yaml +# ❌ Old format (no longer supported) +backend: + auth: + keys: + - secret: your-secret-key + +# ✅ New format +backend: + auth: + externalAccess: + - type: static + options: + token: your-secret-key + subject: external:backstage-plugin # this is the current default for old keys +``` diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index 322edbe6b6..4ba094068e 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -255,10 +255,16 @@ backend: subject: legacy-scaffolder ``` -The old style keys config is also supported as an alternative, but please -consider using the new style above instead: +:::warning +The old style `backend.auth.keys` config is **no longer supported** and has been removed. +If you are still using this configuration, you must migrate to the new `backend.auth.externalAccess` format above. -```yaml title="in e.g. app-config.production.yaml" +You'll see an error like `"Unknown key 'backend.auth.keys'"` during Backstage startup if you haven't migrated yet. +::: + +To migrate from the old config format: + +```yaml title="❌ Old format (no longer supported)" backend: auth: keys: @@ -266,6 +272,22 @@ backend: - secret: my-secret-key-scaffolder ``` +Convert to: + +```yaml title="✅ New format" +backend: + auth: + externalAccess: + - type: legacy + options: + secret: my-secret-key-catalog + subject: external:backstage-plugin + - type: legacy + options: + secret: my-secret-key-scaffolder + subject: external:backstage-plugin +``` + The secrets must be any base64-encoded random data, but for security reasons should be sufficiently long so as not to be easy to guess by brute force. You can for example generate them on the command line: @@ -444,11 +466,17 @@ const decoratedPluginTokenHandler = createServiceFactory({ }); ``` -### Custom ExternalTokenHandler +### Adding custom ExternalTokenHandler The `externalTokenTypeHandlersRef` can be used to add custom external token handlers to the default implementation. -The returned object from the factory function must have a `type` property which is used to identify the handler. The `factory` method is called with an array of `Config` objects, for the given type. The factory method can return a single handler or an array of handlers. The handlers are then used to handle the token for the given type. +Your service factory must return an object with a `type` property that matches the token type in your configuration (e.g., 'custom', 'api-key'). When Backstage encounters tokens of this type, it calls your `initialize` method with all the configuration entries that match this type. Your factory can return either a single token handler or an array of handlers to process and validate these tokens. + +:::note Note + +During token verification, all the token handlers are tested. Consider this when adding many token handlers, as it may impact performance. + +::: For example, if we want to add a custom external token handler for the `custom` type: @@ -474,20 +502,72 @@ And we can implement the custom token handler like this: ```ts import { - TokenHandler, + ExternalTokenHandler, externalTokenTypeHandlersRef, + createExternalTokenHandler, } from '@backstage/backend-defaults/auth'; -import { Config } from '@backstage/config'; import { createServiceFactory } from '@backstage/backend-plugin-api'; const customExternalTokenHandlers = createServiceFactory({ service: externalTokenTypeHandlersRef, deps: {}, async factory() { - return { + return createExternalTokenHandler({ type: 'custom', - factory: (configs: Config[]) => new CustomTokenHandler(configs), // can return a simple handler or an array of handlers - }; + initialize({ options }) { + // Initialize your handler context from config + const customOptions = options.getString('customOptions'); + return { customOptions }; + }, + async verifyToken(token, context) { + // Your custom token validation logic here + // Return undefined if token is invalid + // Return { subject: 'your-subject' } if token is valid + + if (token === 'valid-token') { + return { subject: `custom:${context.customOptions}` }; + } + return undefined; + }, + }); + }, +}); +``` + +The `createExternalTokenHandler` helper simplifies creating external token handlers with the new API: + +- **`type`**: A string identifier for your token handler type that matches the config +- **`initialize`**: Called once for each config entry of this type, receives the config options and returns a context object that will be passed to `verifyToken` +- **`verifyToken`**: Called for each token verification with the token and context, returns the subject if valid or `undefined` if not + +```ts +// Example of a more complex handler with external API call +const apiTokenHandler = createExternalTokenHandler({ + type: 'api-validation', + initialize({ options }) { + const apiBaseUrl = options.getString('apiBaseUrl'); + const apiKey = options.getString('apiKey'); + return { apiBaseUrl, apiKey }; + }, + async verifyToken(token, { apiBaseUrl, apiKey }) { + try { + const response = await fetch(`${apiBaseUrl}/validate-token`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token }), + }); + + if (response.ok) { + const { userId } = await response.json(); + return { subject: `api:${userId}` }; + } + } catch (error) { + // Log error but don't throw - return undefined for invalid tokens + } + return undefined; }, }); ``` diff --git a/packages/backend-defaults/report-auth.api.md b/packages/backend-defaults/report-auth.api.md index 2a80a59d2d..176bdd16de 100644 --- a/packages/backend-defaults/report-auth.api.md +++ b/packages/backend-defaults/report-auth.api.md @@ -60,7 +60,7 @@ export const pluginTokenHandlerDecoratorServiceRef: ServiceRef< >; // @public -export interface TokenHandler { +export interface ExternalTokenHandler { // (undocumented) verifyToken(token: string): Promise< | { diff --git a/packages/backend-defaults/src/entrypoints/auth/DefaultAuthService.ts b/packages/backend-defaults/src/entrypoints/auth/DefaultAuthService.ts index d8cb60e8b5..c2c4a064a3 100644 --- a/packages/backend-defaults/src/entrypoints/auth/DefaultAuthService.ts +++ b/packages/backend-defaults/src/entrypoints/auth/DefaultAuthService.ts @@ -25,7 +25,7 @@ import { import { AuthenticationError } from '@backstage/errors'; import { JsonObject } from '@backstage/types'; import { decodeJwt } from 'jose'; -import { ExternalTokenHandler } from './external/ExternalTokenHandler'; +import { ExternalAuthTokenManager } from './external/export class ExternalAuthTokenManager {'; import { createCredentialsWithNonePrincipal, createCredentialsWithServicePrincipal, @@ -41,7 +41,7 @@ export class DefaultAuthService implements AuthService { constructor( private readonly userTokenHandler: UserTokenHandler, private readonly pluginTokenHandler: PluginTokenHandler, - private readonly externalTokenHandler: ExternalTokenHandler, + private readonly externalTokenHandler: ExternalAuthTokenManager, private readonly pluginId: string, private readonly disableDefaultAuthPolicy: boolean, private readonly pluginKeySource: PluginKeySource, diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts index b808973016..b6d5beefa2 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts @@ -30,9 +30,9 @@ import { setupServer } from 'msw/node'; import { toInternalBackstageCredentials } from './helpers'; import { PluginTokenHandler } from './plugin/PluginTokenHandler'; import { createServiceFactory } from '@backstage/backend-plugin-api'; -import { AccessRestrictionsMap, TokenHandler } from './external/types'; -import { Config } from '@backstage/config'; -import { externalTokenTypeHandlersRef } from './external/ExternalTokenHandler'; + +import { externalTokenTypeHandlersRef } from './external/ExternalAuthTokenHandler'; +import { createExternalTokenHandler } from './external/helpers'; const server = setupServer(); @@ -44,7 +44,7 @@ const mockDeps = [ backend: { baseUrl: 'http://localhost', auth: { - keys: [{ secret: 'abc' }], + // keys: [{ secret: 'abc' }], externalAccess: [ { type: 'static', @@ -460,7 +460,6 @@ describe('authServiceFactory', () => { describe('add custom ExternalTokenHandler', () => { it('should allow custom logic to be injected into the plugin token handler', async () => { const customLogic = jest.fn(); - const customAddEntry = jest.fn(); const customConfig = jest.fn(); const deps = [ discoveryServiceFactory, @@ -469,7 +468,7 @@ describe('authServiceFactory', () => { backend: { baseUrl: 'http://localhost', auth: { - keys: [{ secret: 'abc' }], + // keys: [{ secret: 'abc' }w], externalAccess: [ { type: 'static', @@ -502,37 +501,30 @@ describe('authServiceFactory', () => { }), ]; - class CustomHandler implements TokenHandler { - constructor(options: Config[]) { - for (const option of options) { - customAddEntry(option); - } - } - async verifyToken(token: string): Promise< - | { - subject: string; - allAccessRestrictions?: AccessRestrictionsMap; - } - | undefined - > { + const customExternalTokenHandler = createExternalTokenHandler<{ + [`custom-config`]: string; + foo: string; + }>({ + type: 'custom', + initialize({ options }) { + const customConfigValue = options.getString('custom-config'); + const foo = options.getString('foo'); + customConfig(customConfigValue); + return { [`custom-config`]: customConfigValue, foo }; + }, + async verifyToken(token, ctx) { customLogic(token); return { - subject: 'foo', + subject: ctx.foo, }; - } - } + }, + }); const customPluginTokenHandler = createServiceFactory({ service: externalTokenTypeHandlersRef, deps: {}, async factory() { - return { - type: 'custom', - factory: (configs: Config[]) => { - customConfig(configs); - return new CustomHandler(configs); - }, - }; + return customExternalTokenHandler; }, }); const tester = ServiceFactoryTester.from(authServiceFactory, { @@ -541,29 +533,8 @@ describe('authServiceFactory', () => { const searchAuth = await tester.getSubject('search'); await searchAuth.authenticate('custom-token'); - expect(customAddEntry).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - options: expect.objectContaining({ - [`custom-config`]: 'custom-config', - foo: 'bar', - }), - }), - }), - ); expect(customLogic).toHaveBeenCalledWith('custom-token'); - expect(customConfig).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - data: expect.objectContaining({ - options: expect.objectContaining({ - [`custom-config`]: 'custom-config', - foo: 'bar', - }), - }), - }), - ]), - ); + expect(customConfig).toHaveBeenCalledWith('custom-config'); }); }); }); diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts index 43a3c9da50..34fc3b51ef 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts @@ -21,9 +21,9 @@ import { } from '@backstage/backend-plugin-api'; import { DefaultAuthService } from './DefaultAuthService'; import { - ExternalTokenHandler, + ExternalAuthTokenManager, externalTokenTypeHandlersRef, -} from './external/ExternalTokenHandler'; +} from './external/ExternalAuthTokenHandler'; import { DefaultPluginTokenHandler, PluginTokenHandler, @@ -107,7 +107,7 @@ export const authServiceFactory = createServiceFactory({ }), ); - const externalTokens = ExternalTokenHandler.create({ + const externalTokens = ExternalAuthTokenManager.create({ ownPluginId: plugin.getId(), config, logger, diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts index 3348d84e93..d97875d228 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts @@ -15,8 +15,9 @@ */ import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api'; -import { ExternalTokenHandler } from './ExternalTokenHandler'; -import { TokenHandler } from './types'; +import { ExternalAuthTokenManager } from './ExternalAuthTokenHandler'; +import { createExternalTokenHandler } from './helpers'; +import { AccessRestrictionsMap, ExternalTokenHandler } from './types'; import { mockServices, registerMswTestHooks, @@ -84,25 +85,51 @@ describe('ExternalTokenHandler', () => { registerMswTestHooks(server); it('skips over inner handlers that do not match, and applies plugin restrictions', async () => { - const handler1: TokenHandler = { + const handler1: ExternalTokenHandler = createExternalTokenHandler({ + type: 'type1', + initialize: jest.fn().mockResolvedValue(undefined), verifyToken: jest.fn().mockResolvedValue(undefined), - }; + }); - const handler2: TokenHandler = { - verifyToken: jest.fn().mockResolvedValue({ - subject: 'sub', - allAccessRestrictions: new Map( - Object.entries({ - plugin1: { - permissionNames: ['do.it'], - } satisfies BackstagePrincipalAccessRestrictions, - }), - ), + const handler2: ExternalTokenHandler = + createExternalTokenHandler({ + type: 'type2', + initialize: jest.fn().mockResolvedValue(undefined), + verifyToken: jest.fn().mockResolvedValue({ + subject: 'sub', + }), + }); + + const accessRestrictions: AccessRestrictionsMap = new Map( + Object.entries({ + plugin1: { + permissionNames: ['do.it'], + } satisfies BackstagePrincipalAccessRestrictions, }), - }; + ); - const plugin1 = new ExternalTokenHandler('plugin1', [handler1, handler2]); - const plugin2 = new ExternalTokenHandler('plugin2', [handler1, handler2]); + const plugin1 = new ExternalAuthTokenManager('plugin1', [ + { + context: undefined, + handler: handler1, + }, + { + context: undefined, + handler: handler2, + allAccessRestrictions: accessRestrictions, + }, + ]); + const plugin2 = new ExternalAuthTokenManager('plugin2', [ + { + context: undefined, + handler: handler1, + }, + { + context: undefined, + handler: handler2, + allAccessRestrictions: accessRestrictions, + }, + ]); await expect(plugin1.verifyToken('token')).resolves.toEqual({ subject: 'sub', @@ -133,7 +160,7 @@ describe('ExternalTokenHandler', () => { ), ); - const handler = ExternalTokenHandler.create({ + const handler = ExternalAuthTokenManager.create({ ownPluginId: 'catalog', logger: mockServices.logger.mock(), config: mockServices.rootConfig({ @@ -222,12 +249,10 @@ describe('ExternalTokenHandler', () => { ), ); - const customHandler: TokenHandler = { - verifyToken: jest.fn(), - }; - const customHandlerFactory = jest.fn(() => customHandler); + const verifyMock = jest.fn().mockResolvedValue({}); + const initializeMock = jest.fn().mockReturnValue({ context: 'a' }); - const handler = ExternalTokenHandler.create({ + const handler = ExternalAuthTokenManager.create({ ownPluginId: 'catalog', logger: mockServices.logger.mock(), config: mockServices.rootConfig({ @@ -252,30 +277,23 @@ describe('ExternalTokenHandler', () => { }, }), externalTokenHandlers: [ - { + createExternalTokenHandler({ type: 'internal-custom', - factory: customHandlerFactory, - }, + initialize: initializeMock, + verifyToken: verifyMock, + }), ], }); - expect(customHandlerFactory).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - data: { - type: 'internal-custom', - options: { - issuer: 'my-company', - subject: 'internal-subject', - audience: 'backstage', - }, - accessRestrictions: [ - { plugin: 'catalog', permission: 'catalog.entity.read' }, - ], - }, - }), - ]), - ); + expect(initializeMock).toHaveBeenCalledWith({ + options: expect.objectContaining({ + data: { + issuer: 'my-company', + subject: 'internal-subject', + audience: 'backstage', + }, + }), + }); const customToken = await factory.issueToken({ claims: { sub: 'internal-subject' }, @@ -283,11 +301,11 @@ describe('ExternalTokenHandler', () => { await handler.verifyToken(customToken); - expect(customHandler.verifyToken).toHaveBeenCalled(); + expect(verifyMock).toHaveBeenCalledWith(customToken, { context: 'a' }); }); it('should fail if config contains types not declared', async () => { const createHandler = () => - ExternalTokenHandler.create({ + ExternalAuthTokenManager.create({ ownPluginId: 'catalog', logger: mockServices.logger.mock(), config: mockServices.rootConfig({ @@ -314,13 +332,13 @@ describe('ExternalTokenHandler', () => { }); expect(createHandler).toThrowErrorMatchingInlineSnapshot( - `"Unknown type(s) 'internal-custom' in backend.auth.externalAccess, expected one of 'static', 'legacy', 'jwks'"`, + `"Unknown type 'internal-custom' in backend.auth.externalAccess, expected one of 'static', 'legacy', 'jwks'"`, ); }); it('should show valid custom types in errors', async () => { const createHandler = () => - ExternalTokenHandler.create({ + ExternalAuthTokenManager.create({ ownPluginId: 'catalog', logger: mockServices.logger.mock(), config: mockServices.rootConfig({ @@ -345,68 +363,18 @@ describe('ExternalTokenHandler', () => { }, }), externalTokenHandlers: [ - { + createExternalTokenHandler({ type: 'internal-custom', - factory: () => ({ - verifyToken: jest.fn(), + initialize: jest.fn().mockResolvedValue(undefined), + verifyToken: jest.fn().mockResolvedValue({ + subject: 'sub', }), - }, + }), ], }); expect(createHandler).toThrowErrorMatchingInlineSnapshot( - `"Unknown type(s) 'internal-custom-invalid' in backend.auth.externalAccess, expected one of 'static', 'legacy', 'jwks', 'internal-custom'"`, - ); - }); - it('should show all invalid config types', async () => { - const createHandler = () => - ExternalTokenHandler.create({ - ownPluginId: 'catalog', - logger: mockServices.logger.mock(), - config: mockServices.rootConfig({ - data: { - backend: { - auth: { - externalAccess: [ - { - type: 'internal-custom-invalid', - options: { - issuer: 'my-company', - subject: 'internal-subject', - audience: 'backstage', - }, - accessRestrictions: [ - { plugin: 'catalog', permission: 'catalog.entity.read' }, - ], - }, - { - type: 'internal-custom-invalid-2', - options: { - issuer: 'my-company', - subject: 'internal-subject', - audience: 'backstage', - }, - accessRestrictions: [ - { plugin: 'catalog', permission: 'catalog.entity.read' }, - ], - }, - ], - }, - }, - }, - }), - externalTokenHandlers: [ - { - type: 'internal-custom', - factory: () => ({ - verifyToken: jest.fn(), - }), - }, - ], - }); - - expect(createHandler).toThrowErrorMatchingInlineSnapshot( - `"Unknown type(s) 'internal-custom-invalid, internal-custom-invalid-2' in backend.auth.externalAccess, expected one of 'static', 'legacy', 'jwks', 'internal-custom'"`, + `"Unknown type 'internal-custom-invalid' in backend.auth.externalAccess, expected one of 'static', 'legacy', 'jwks', 'internal-custom'"`, ); }); }); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts index c3fab70026..bf57ad42f4 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts @@ -21,85 +21,82 @@ import { RootConfigService, } from '@backstage/backend-plugin-api'; import { NotAllowedError } from '@backstage/errors'; -import { LegacyConfigWrapper, LegacyTokenHandler } from './legacy'; -import { StaticTokenHandler } from './static'; -import { JWKSHandler } from './jwks'; -import { TokenHandler } from './types'; -import { Config } from '@backstage/config'; -import { groupBy } from 'lodash'; -import { TokenTypeHandler } from './types'; +import { legacyTokenHandler } from './legacy'; +import { staticTokenHandler } from './static'; +import { jwksTokenHandler } from './jwks'; +import { AccessRestrictionsMap, ExternalTokenHandler } from './types'; +import { readAccessRestrictionsFromConfig } from './helpers'; const NEW_CONFIG_KEY = 'backend.auth.externalAccess'; const OLD_CONFIG_KEY = 'backend.auth.keys'; let loggedDeprecationWarning = false; -type LegacyTokenTypeHandler = { - type: 'legacy'; - /** - * A factory function that takes all token configuration for a given type - * and returns a TokenHandler or an array of TokenHandlers. - */ - factory: ( - configs: ( - | import('@backstage/config').Config - | { legacy: true; config: import('@backstage/config').Config } - )[], - ) => TokenHandler | TokenHandler[]; -}; - /** * @public * This service is used to decorate the default plugin token handler with custom logic. */ -export const externalTokenTypeHandlersRef = createServiceRef({ +export const externalTokenTypeHandlersRef = createServiceRef< + ExternalTokenHandler +>({ id: 'core.auth.externalTokenHandlers', multiton: true, // defaultFactory // :pepe-think: seems like is not possible to use defaultFactory with multiton }); -const defaultHandlers: (TokenTypeHandler | LegacyTokenTypeHandler)[] = [ - { - type: 'static', - factory: configs => new StaticTokenHandler(configs), - }, - { - type: 'legacy', - factory: (configs: (Config | { legacy: true; config: Config })[]) => - new LegacyTokenHandler(configs), - }, - { - type: 'jwks', - factory: configs => new JWKSHandler(configs), - }, +const defaultHandlers: ExternalTokenHandler[] = [ + staticTokenHandler, + legacyTokenHandler, + jwksTokenHandler, ]; +type ContextMapEntry = { + context: T; + handler: ExternalTokenHandler; + allAccessRestrictions?: AccessRestrictionsMap; +}; /** * Handles all types of external caller token types (i.e. not Backstage user * tokens, nor Backstage backend plugin tokens). * * @internal */ -export class ExternalTokenHandler { +export class ExternalAuthTokenManager { static create(options: { ownPluginId: string; config: RootConfigService; logger: LoggerService; - externalTokenHandlers?: TokenTypeHandler[]; - }): ExternalTokenHandler { + externalTokenHandlers?: ExternalTokenHandler[]; + }): ExternalAuthTokenManager { const { ownPluginId, config, - logger, externalTokenHandlers: customHandlers, } = options; const handlersTypes = [...defaultHandlers, ...(customHandlers ?? [])]; const handlerConfigs = config.getOptionalConfigArray(NEW_CONFIG_KEY) ?? []; - const handlerConfigByType: Record & { - legacy?: (Config | LegacyConfigWrapper)[]; - } = groupBy(handlerConfigs, (handlerConfig: Config) => - handlerConfig.getString('type'), + const contexts: ContextMapEntry[] = handlerConfigs.map( + handlerConfig => { + const type = handlerConfig.getString('type'); + + const handler = handlersTypes.find(h => h.type === type); + if (!handler) { + const valid = handlersTypes.map(h => `'${h.type}'`).join(', '); + throw new Error( + `Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`, + ); + } + return { + context: handler.initialize({ + options: handlerConfig.getConfig('options'), + }), + handler, + allAccessRestrictions: handlerConfig + ? readAccessRestrictionsFromConfig(handlerConfig) + : undefined, + }; + }, ); // Load the old keys too @@ -107,45 +104,22 @@ export class ExternalTokenHandler { if (legacyConfigs.length && !loggedDeprecationWarning) { loggedDeprecationWarning = true; - logger.warn( - `DEPRECATION WARNING: The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`, - ); - } - for (const handlerConfig of legacyConfigs) { - handlerConfigByType.legacy ??= []; - handlerConfigByType.legacy.push({ legacy: true, config: handlerConfig }); - } - - const invalidTypes = Object.keys(handlerConfigByType).filter( - type => !handlersTypes.some(handler => handler.type === type), - ); - - if (invalidTypes.length > 0) { - const valid = handlersTypes - .map(handler => `'${handler.type}'`) - .join(', '); + // :pepe-think: this message was here for more than a year, and replacing this simplifies things a lot throw new Error( - `Unknown type(s) '${invalidTypes.join( - ', ', - )}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`, + `The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`, ); } - const handlers = handlersTypes.flatMap(handler => { - const configs = handlerConfigByType[handler.type] ?? []; - const handlerInstances = handler.factory(configs); - if (Array.isArray(handlerInstances)) { - return handlerInstances; - } - return [handlerInstances]; - }); - - return new ExternalTokenHandler(ownPluginId, handlers); + return new ExternalAuthTokenManager(ownPluginId, contexts); } constructor( private readonly ownPluginId: string, - private readonly handlers: TokenHandler[], + private readonly contexts: { + context: unknown; + handler: ExternalTokenHandler; + allAccessRestrictions?: AccessRestrictionsMap; + }[], ) {} async verifyToken(token: string): Promise< @@ -155,10 +129,9 @@ export class ExternalTokenHandler { } | undefined > { - for (const handler of this.handlers) { - const result = await handler.verifyToken(token); + for (const { handler, allAccessRestrictions, context } of this.contexts) { + const result = await handler.verifyToken(token, context); if (result) { - const { allAccessRestrictions, ...rest } = result; if (allAccessRestrictions) { const accessRestrictions = allAccessRestrictions.get( this.ownPluginId, @@ -173,12 +146,12 @@ export class ExternalTokenHandler { } return { - ...rest, + ...result, accessRestrictions, }; } - return rest; + return result; } } diff --git a/packages/backend-defaults/src/entrypoints/auth/external/helpers.ts b/packages/backend-defaults/src/entrypoints/auth/external/helpers.ts index 4a01117e28..296ec2e72b 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/helpers.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/helpers.ts @@ -15,7 +15,7 @@ */ import { Config } from '@backstage/config'; -import { AccessRestrictionsMap } from './types'; +import { AccessRestrictionsMap, ExternalTokenHandler } from './types'; /** * Parses and returns the `accessRestrictions` configuration from an @@ -147,3 +147,9 @@ function readPermissionAttributes(externalAccessEntryConfig: Config) { return Object.keys(result).length ? result : undefined; } + +export function createExternalTokenHandler( + handler: ExternalTokenHandler, +): ExternalTokenHandler { + return handler; +} diff --git a/packages/backend-defaults/src/entrypoints/auth/external/jwks.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/jwks.test.ts index eefbce5093..778e5090bf 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/jwks.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/jwks.test.ts @@ -20,7 +20,7 @@ import { SignJWT, exportJWK, generateKeyPair } from 'jose'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { v4 as uuid } from 'uuid'; -import { JWKSHandler } from './jwks'; +import { jwksTokenHandler } from './jwks'; // Simplified copy of TokenFactory in @backstage/plugin-auth-backend interface AnyJWK extends Record { @@ -101,108 +101,45 @@ describe('JWKSHandler', () => { it('verifies token with valid entry', async () => { const validEntry = { - options: { - url: `${mockBaseUrl}/.well-known/jwks.json`, - algorithm: 'RS256', - issuer: mockBaseUrl, - audience: 'backstage', - }, + url: `${mockBaseUrl}/.well-known/jwks.json`, + algorithm: 'RS256', + issuer: mockBaseUrl, + audience: 'backstage', }; - const jwksHandler = new JWKSHandler([new ConfigReader(validEntry)]); + const context = jwksTokenHandler.initialize({ + options: new ConfigReader(validEntry), + }); const token = await factory.issueToken({ claims: { sub: mockSubject }, }); - const result = await jwksHandler.verifyToken(token); + const result = await jwksTokenHandler.verifyToken(token, context); expect(result).toEqual({ subject: `external:${mockSubject}` }); }); - it('skips invalid entry and continues verification', async () => { - const invalidEntry = { - options: { - url: `${mockBaseUrl}/.well-known/jwks.json`, - algorithm: 'RS256', - issuer: ['fakeIssuer'], - audience: ['fakeAud'], - }, - }; - - const validEntry = { - options: { - url: `${mockBaseUrl}/.well-known/jwks.json`, - algorithm: 'RS256', - issuer: ['multiple-issuers', mockBaseUrl], - audience: ['multiple-audiences', 'backstage'], - }, - }; - const jwksHandler = new JWKSHandler([ - new ConfigReader(invalidEntry), - new ConfigReader(validEntry), - ]); - - const token = await factory.issueToken({ - claims: { sub: mockSubject }, - }); - - const result = await jwksHandler.verifyToken(token); - - expect(result).toEqual({ subject: `external:${mockSubject}` }); - }); - - it('returns undefined if no valid entry found', async () => { - const invalidEntry1 = { - options: { - url: `${mockBaseUrl}/.well-known/jwks.json`, - algorithm: 'RS256', - issuer: 'wrong', - }, - }; - - const invalidEntry2 = { - options: { - url: `${mockBaseUrl}/.well-known/jwks.json`, - algorithm: ['HS256'], - audience: 'wrong', - }, - }; - const jwksHandler = new JWKSHandler([ - new ConfigReader(invalidEntry1), - new ConfigReader(invalidEntry2), - ]); - - const token = await factory.issueToken({ - claims: { sub: mockSubject }, - }); - - const result = await jwksHandler.verifyToken(token); - - expect(result).toBeUndefined(); - }); - it('rejects bad config', () => { expect(() => { - return new JWKSHandler([ - new ConfigReader({ - options: { url: 'https://exampl e.com/jwks' }, + return jwksTokenHandler.initialize({ + options: new ConfigReader({ + url: 'https://exampl e.com/jwks', }), - ]); + }); }).toThrow('Illegal JWKS URL, must be a set of non-space characters'); expect(() => { - return new JWKSHandler([ - new ConfigReader({ - options: { - url: 'https://example.com/jwks\n', - }, + return jwksTokenHandler.initialize({ + options: new ConfigReader({ + url: 'https://example.com/jwks\n', }), - ]); + }); }).toThrow('Illegal JWKS URL, must be a set of non-space characters'); }); it('gracefully handles no added tokens', async () => { - const handler = new JWKSHandler([]); - await expect(handler.verifyToken('ghi')).resolves.toBeUndefined(); + await expect( + jwksTokenHandler.verifyToken('ghi', {} as any), + ).resolves.toBeUndefined(); }); it('uses custom subject prefix if provided', async () => { @@ -215,38 +152,18 @@ describe('JWKSHandler', () => { subjectPrefix: 'custom-prefix', }, }; - const jwksHandler = new JWKSHandler([new ConfigReader(validEntry)]); + const context = jwksTokenHandler.initialize({ + options: new ConfigReader(validEntry.options), + }); const token = await factory.issueToken({ claims: { sub: mockSubject }, }); - const result = await jwksHandler.verifyToken(token); + const result = await jwksTokenHandler.verifyToken(token, context); expect(result).toEqual({ subject: `external:${validEntry.options.subjectPrefix}:${mockSubject}`, }); }); - - it('carries over access restrictions', async () => { - const jwksHandler = new JWKSHandler([ - new ConfigReader({ - options: { - url: `${mockBaseUrl}/.well-known/jwks.json`, - }, - accessRestrictions: [{ plugin: 'scaffolder', permission: 'do.it' }], - }), - ]); - - const token = await factory.issueToken({ claims: { sub: mockSubject } }); - - await expect(jwksHandler.verifyToken(token)).resolves.toEqual({ - subject: `external:${mockSubject}`, - allAccessRestrictions: new Map( - Object.entries({ - scaffolder: { permissionNames: ['do.it'] }, - }), - ), - }); - }); }); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts b/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts index 5f7fcb4127..892fcf2181 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts @@ -15,56 +15,40 @@ */ import { jwtVerify, createRemoteJWKSet, JWTVerifyGetKey } from 'jose'; -import { Config } from '@backstage/config'; import { + createExternalTokenHandler, readAccessRestrictionsFromConfig, readStringOrStringArrayFromConfig, } from './helpers'; -import { AccessRestrictionsMap, TokenHandler } from './types'; +import { AccessRestrictionsMap } from './types'; -/** - * Handles `type: jwks` access. - * - * @internal - */ -export class JWKSHandler implements TokenHandler { - #entries: Array<{ - algorithms?: string[]; - audiences?: string[]; - issuers?: string[]; - subjectPrefix?: string; - url: URL; - jwks: JWTVerifyGetKey; - allAccessRestrictions?: AccessRestrictionsMap; - }> = []; +type JWKSTokenContext = { + algorithms?: string[]; + audiences?: string[]; + issuers?: string[]; + subjectPrefix?: string; + url: URL; + jwks: JWTVerifyGetKey; + allAccessRestrictions?: AccessRestrictionsMap; +}; - constructor(configs: Config[]) { - for (const config of configs) { - this.add(config); - } - } - private add(config: Config) { - if (!config.getString('options.url').match(/^\S+$/)) { +export const jwksTokenHandler = createExternalTokenHandler({ + type: 'jwks', + initialize({ options }): JWKSTokenContext { + if (!options.getString('url').match(/^\S+$/)) { throw new Error( 'Illegal JWKS URL, must be a set of non-space characters', ); } - const algorithms = readStringOrStringArrayFromConfig( - config, - 'options.algorithm', - ); - const issuers = readStringOrStringArrayFromConfig(config, 'options.issuer'); - const audiences = readStringOrStringArrayFromConfig( - config, - 'options.audience', - ); - const subjectPrefix = config.getOptionalString('options.subjectPrefix'); - const url = new URL(config.getString('options.url')); + const algorithms = readStringOrStringArrayFromConfig(options, 'algorithm'); + const issuers = readStringOrStringArrayFromConfig(options, 'issuer'); + const audiences = readStringOrStringArrayFromConfig(options, 'audience'); + const subjectPrefix = options.getOptionalString('subjectPrefix'); + const url = new URL(options.getString('url')); const jwks = createRemoteJWKSet(url); - const allAccessRestrictions = readAccessRestrictionsFromConfig(config); - - this.#entries.push({ + const allAccessRestrictions = readAccessRestrictionsFromConfig(options); + return { algorithms, audiences, issuers, @@ -72,34 +56,31 @@ export class JWKSHandler implements TokenHandler { subjectPrefix, url, allAccessRestrictions, - }); - return this; - } + }; + }, - async verifyToken(token: string) { - for (const entry of this.#entries) { - try { - const { - payload: { sub }, - } = await jwtVerify(token, entry.jwks, { - algorithms: entry.algorithms, - issuer: entry.issuers, - audience: entry.audiences, - }); + async verifyToken(token: string, context: JWKSTokenContext) { + try { + const { + payload: { sub }, + } = await jwtVerify(token, context.jwks, { + algorithms: context.algorithms, + issuer: context.issuers, + audience: context.audiences, + }); - if (sub) { - const prefix = entry.subjectPrefix - ? `external:${entry.subjectPrefix}:` - : 'external:'; - return { - subject: `${prefix}${sub}`, - allAccessRestrictions: entry.allAccessRestrictions, - }; - } - } catch { - continue; + if (sub) { + const prefix = context.subjectPrefix + ? `external:${context.subjectPrefix}:` + : 'external:'; + return { + subject: `${prefix}${sub}`, + allAccessRestrictions: context.allAccessRestrictions, + }; } + } catch { + return undefined; } return undefined; - } -} + }, +}); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts index 4892d0a21a..43160b46da 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts @@ -18,49 +18,27 @@ import { ConfigReader } from '@backstage/config'; import { randomBytes } from 'crypto'; import { SignJWT, importJWK } from 'jose'; import { DateTime } from 'luxon'; -import { LegacyTokenHandler } from './legacy'; +import { legacyTokenHandler } from './legacy'; describe('LegacyTokenHandler', () => { const key1 = randomBytes(24); const key2 = randomBytes(24); - const key3 = randomBytes(24); - const accessRestrictions1 = new Map( - Object.entries({ - scaffolder: {}, - }), - ); - const accessRestrictions2 = new Map( - Object.entries({ - catalog: { permissionNames: ['catalog.entity.read'] }, - }), - ); - const configs = [ - new ConfigReader({ - options: { - secret: key1.toString('base64'), - subject: 'key1', - }, - accessRestrictions: [{ plugin: 'scaffolder' }], - }), + const tokenHandler = legacyTokenHandler; - new ConfigReader({ - options: { - secret: key2.toString('base64'), - subject: 'key2', - }, - accessRestrictions: [ - { plugin: 'catalog', permission: 'catalog.entity.read' }, - ], + const context1 = tokenHandler.initialize({ + options: new ConfigReader({ + secret: key1.toString('base64'), + subject: 'key1', }), - { - legacy: true, - config: new ConfigReader({ - secret: key3.toString('base64'), - }), - }, - ]; - const tokenHandler = new LegacyTokenHandler(configs); + }); + + const context2 = tokenHandler.initialize({ + options: new ConfigReader({ + secret: key2.toString('base64'), + subject: 'key2', + }), + }); it('should verify valid tokens', async () => { const token1 = await new SignJWT({ @@ -70,9 +48,8 @@ describe('LegacyTokenHandler', () => { .setProtectedHeader({ alg: 'HS256' }) .sign(key1); - await expect(tokenHandler.verifyToken(token1)).resolves.toEqual({ + await expect(tokenHandler.verifyToken(token1, context1)).resolves.toEqual({ subject: 'key1', - allAccessRestrictions: accessRestrictions1, }); const token2 = await new SignJWT({ @@ -82,20 +59,8 @@ describe('LegacyTokenHandler', () => { .setProtectedHeader({ alg: 'HS256' }) .sign(key2); - await expect(tokenHandler.verifyToken(token2)).resolves.toEqual({ + await expect(tokenHandler.verifyToken(token2, context2)).resolves.toEqual({ subject: 'key2', - allAccessRestrictions: accessRestrictions2, - }); - - const token3 = await new SignJWT({ - sub: 'backstage-server', - exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(), - }) - .setProtectedHeader({ alg: 'HS256' }) - .sign(key3); - - await expect(tokenHandler.verifyToken(token3)).resolves.toEqual({ - subject: 'external:backstage-plugin', }); }); @@ -107,10 +72,18 @@ describe('LegacyTokenHandler', () => { .setProtectedHeader({ alg: 'HS256' }) .sign(key1); - await expect(tokenHandler.verifyToken(validToken)).resolves.toBeUndefined(); + await expect( + tokenHandler.verifyToken(validToken, context1), + ).resolves.toBeUndefined(); + await expect( + tokenHandler.verifyToken(validToken, context2), + ).resolves.toBeUndefined(); await expect( - tokenHandler.verifyToken('statickeyblaaa'), + tokenHandler.verifyToken('statickeyblaaa', context1), + ).resolves.toBeUndefined(); + await expect( + tokenHandler.verifyToken('statickeyblaaa', context2), ).resolves.toBeUndefined(); const randomToken = await new SignJWT({ @@ -120,7 +93,10 @@ describe('LegacyTokenHandler', () => { .setProtectedHeader({ alg: 'HS256' }) .sign(randomBytes(24)); await expect( - tokenHandler.verifyToken(randomToken), + tokenHandler.verifyToken(randomToken, context1), + ).resolves.toBeUndefined(); + await expect( + tokenHandler.verifyToken(randomToken, context2), ).resolves.toBeUndefined(); const mockPublicKey = { @@ -144,7 +120,10 @@ describe('LegacyTokenHandler', () => { .sign(await importJWK(mockPrivateKey)); await expect( - tokenHandler.verifyToken(keyWithWrongAlg), + tokenHandler.verifyToken(keyWithWrongAlg, context1), + ).resolves.toBeUndefined(); + await expect( + tokenHandler.verifyToken(keyWithWrongAlg, context2), ).resolves.toBeUndefined(); }); @@ -157,151 +136,134 @@ describe('LegacyTokenHandler', () => { .setProtectedHeader({ alg: 'HS256' }) .sign(key1); - await expect(tokenHandler.verifyToken(keyWithWrongExp)).rejects.toThrow( - /\"exp\" claim must be a number/, - ); + await expect( + tokenHandler.verifyToken(keyWithWrongExp, context1), + ).rejects.toThrow(/\"exp\" claim must be a number/); }); it('rejects bad config', () => { - const handler = new LegacyTokenHandler([]); + const handler = legacyTokenHandler; // new style add, bad secrets - expect( - () => - new LegacyTokenHandler([ - new ConfigReader({ - options: { _missingsecret: true, subject: 'ok' }, - }), - ]), - ).toThrowErrorMatchingInlineSnapshot( - `"Missing required config value at 'options.secret' in 'mock-config'"`, - ); - expect( - () => - new LegacyTokenHandler([ - new ConfigReader({ options: { secret: '', subject: 'ok' } }), - ]), - ).toThrowErrorMatchingInlineSnapshot( - `"Invalid type in config for key 'options.secret' in 'mock-config', got empty-string, wanted string"`, - ); - expect( - () => - new LegacyTokenHandler([ - new ConfigReader({ - options: { secret: 'has spaces', subject: 'ok' }, - }), - ]), - ).toThrowErrorMatchingInlineSnapshot( - `"Illegal secret, must be a valid base64 string"`, - ); - expect( - () => - new LegacyTokenHandler([ - new ConfigReader({ - options: { secret: 'hasnewline\n', subject: 'ok' }, - }), - ]), - ).toThrowErrorMatchingInlineSnapshot( - `"Illegal secret, must be a valid base64 string"`, - ); - expect( - () => - new LegacyTokenHandler([ - new ConfigReader({ options: { secret: 3, subject: 'ok' } }), - ]), - ).toThrowErrorMatchingInlineSnapshot( - `"Invalid type in config for key 'options.secret' in 'mock-config', got number, wanted string"`, - ); - - // new style add, bad subjects - expect( - () => - new LegacyTokenHandler([ - new ConfigReader({ - options: { secret: 'b2s=', _missingsubject: true }, - }), - ]), - ).toThrowErrorMatchingInlineSnapshot( - `"Missing required config value at 'options.subject' in 'mock-config'"`, - ); - expect( - () => - new LegacyTokenHandler([ - new ConfigReader({ options: { secret: 'b2s=', subject: '' } }), - ]), - ).toThrowErrorMatchingInlineSnapshot( - `"Invalid type in config for key 'options.subject' in 'mock-config', got empty-string, wanted string"`, - ); - expect( - () => - new LegacyTokenHandler([ - new ConfigReader({ - options: { secret: 'b2s=', subject: 'has spaces' }, - }), - ]), - ).toThrowErrorMatchingInlineSnapshot( - `"Illegal subject, must be a set of non-space characters"`, - ); - expect( - () => - new LegacyTokenHandler([ - new ConfigReader({ - options: { secret: 'b2s=', subject: 'hasnewline\n' }, - }), - ]), - ).toThrowErrorMatchingInlineSnapshot( - `"Illegal subject, must be a set of non-space characters"`, - ); - expect( - () => - new LegacyTokenHandler([ - new ConfigReader({ options: { secret: 'b2s=', subject: 3 } }), - ]), - ).toThrowErrorMatchingInlineSnapshot( - `"Invalid type in config for key 'options.subject' in 'mock-config', got number, wanted string"`, - ); - - // new style add, bad access restrictions - expect( - () => - new LegacyTokenHandler([ - new ConfigReader({ - options: { secret: 'b2s=', subject: 'subject' }, - accessRestrictions: [{ plugin: ['a'] }], - }), - ]), - ).toThrowErrorMatchingInlineSnapshot( - `"Invalid type in config for key 'accessRestrictions[0].plugin' in 'mock-config', got array, wanted string"`, - ); - - // old style add expect(() => - handler.addOld(new ConfigReader({ secret: 'b2s=' })), - ).not.toThrow(); - expect(() => - handler.addOld(new ConfigReader({ _missingsecret: true })), + handler.initialize({ + options: new ConfigReader({ + _missingsecret: true, + subject: 'ok', + }), + }), ).toThrowErrorMatchingInlineSnapshot( `"Missing required config value at 'secret' in 'mock-config'"`, ); expect(() => - handler.addOld(new ConfigReader({ secret: '' })), + handler.initialize({ + options: new ConfigReader({ secret: '', subject: 'ok' }), + }), ).toThrowErrorMatchingInlineSnapshot( `"Invalid type in config for key 'secret' in 'mock-config', got empty-string, wanted string"`, ); expect(() => - handler.addOld(new ConfigReader({ secret: 'has spaces' })), + handler.initialize({ + options: new ConfigReader({ + secret: 'has spaces', + subject: 'ok', + }), + }), ).toThrowErrorMatchingInlineSnapshot( `"Illegal secret, must be a valid base64 string"`, ); expect(() => - handler.addOld(new ConfigReader({ secret: 'hasnewline\n' })), + handler.initialize({ + options: new ConfigReader({ + secret: 'hasnewline\n', + subject: 'ok', + }), + }), ).toThrowErrorMatchingInlineSnapshot( `"Illegal secret, must be a valid base64 string"`, ); expect(() => - handler.addOld(new ConfigReader({ secret: 3 })), + handler.initialize({ + options: new ConfigReader({ secret: 3, subject: 'ok' }), + }), ).toThrowErrorMatchingInlineSnapshot( `"Invalid type in config for key 'secret' in 'mock-config', got number, wanted string"`, ); + + // new style add, bad subjects + expect(() => + handler.initialize({ + options: new ConfigReader({ + secret: 'b2s=', + _missingsubject: true, + }), + }), + ).toThrowErrorMatchingInlineSnapshot( + `"Missing required config value at 'subject' in 'mock-config'"`, + ); + expect(() => + handler.initialize({ + options: new ConfigReader({ secret: 'b2s=', subject: '' }), + }), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid type in config for key 'subject' in 'mock-config', got empty-string, wanted string"`, + ); + expect(() => + handler.initialize({ + options: new ConfigReader({ + secret: 'b2s=', + subject: 'has spaces', + }), + }), + ).toThrowErrorMatchingInlineSnapshot( + `"Illegal subject, must be a set of non-space characters"`, + ); + expect(() => + handler.initialize({ + options: new ConfigReader({ + secret: 'b2s=', + subject: 'hasnewline\n', + }), + }), + ).toThrowErrorMatchingInlineSnapshot( + `"Illegal subject, must be a set of non-space characters"`, + ); + expect(() => + handler.initialize({ + options: new ConfigReader({ secret: 'b2s=', subject: 3 }), + }), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid type in config for key 'subject' in 'mock-config', got number, wanted string"`, + ); + + // // old style add + // expect(() => + // handler.addOld(new ConfigReader({ secret: 'b2s=' })), + // ).not.toThrow(); + // expect(() => + // handler.addOld(new ConfigReader({ _missingsecret: true })), + // ).toThrowErrorMatchingInlineSnapshot( + // `"Missing required config value at 'secret' in 'mock-config'"`, + // ); + // expect(() => + // handler.addOld(new ConfigReader({ secret: '' })), + // ).toThrowErrorMatchingInlineSnapshot( + // `"Invalid type in config for key 'secret' in 'mock-config', got empty-string, wanted string"`, + // ); + // expect(() => + // handler.addOld(new ConfigReader({ secret: 'has spaces' })), + // ).toThrowErrorMatchingInlineSnapshot( + // `"Illegal secret, must be a valid base64 string"`, + // ); + // expect(() => + // handler.addOld(new ConfigReader({ secret: 'hasnewline\n' })), + // ).toThrowErrorMatchingInlineSnapshot( + // `"Illegal secret, must be a valid base64 string"`, + // ); + // expect(() => + // handler.addOld(new ConfigReader({ secret: 3 })), + // ).toThrowErrorMatchingInlineSnapshot( + // `"Invalid type in config for key 'secret' in 'mock-config', got number, wanted string"`, + // ); }); }); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts b/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts index 8be0de19e4..f9932a8735 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts @@ -16,126 +16,78 @@ import { Config } from '@backstage/config'; import { base64url, decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose'; -import { readAccessRestrictionsFromConfig } from './helpers'; -import { AccessRestrictionsMap, TokenHandler } from './types'; + +import { createExternalTokenHandler } from './helpers'; export type LegacyConfigWrapper = { legacy: boolean; config: Config; }; -/** - * Handles `type: legacy` access. - * - * @internal - */ -export class LegacyTokenHandler implements TokenHandler { - #entries = new Array<{ - key: Uint8Array; - result: { - subject: string; - allAccessRestrictions?: AccessRestrictionsMap; - }; - }>(); - constructor(configs: (Config | LegacyConfigWrapper)[]) { - for (const config of configs) { - if (isLegacy(config)) { - this.addOld(config.config); - continue; +type LegacyTokenHandlerContext = { + key: Uint8Array; + + subject: string; +}; + +export const legacyTokenHandler = + createExternalTokenHandler({ + type: 'legacy', + initialize(ctx: { options: Config }): LegacyTokenHandlerContext { + const secret = ctx.options.getString('secret'); + const subject = ctx.options.getString('subject'); + + if (!secret.match(/^\S+$/)) { + throw new Error('Illegal secret, must be a valid base64 string'); + } else if (!subject.match(/^\S+$/)) { + throw new Error( + 'Illegal subject, must be a set of non-space characters', + ); } - this.add(config); - } - } - add(config: Config) { - const allAccessRestrictions = readAccessRestrictionsFromConfig(config); - this.#doAdd( - config.getString('options.secret'), - config.getString('options.subject'), - allAccessRestrictions, - ); - return this; - } - - // used only for the old backend.auth.keys array - addOld(config: Config) { - // This choice of subject is for compatibility reasons - this.#doAdd(config.getString('secret'), 'external:backstage-plugin'); - } - - #doAdd( - secret: string, - subject: string, - allAccessRestrictions?: AccessRestrictionsMap, - ) { - if (!secret.match(/^\S+$/)) { - throw new Error('Illegal secret, must be a valid base64 string'); - } else if (!subject.match(/^\S+$/)) { - throw new Error('Illegal subject, must be a set of non-space characters'); - } - - let key: Uint8Array; - try { - key = base64url.decode(secret); - } catch { - throw new Error('Illegal secret, must be a valid base64 string'); - } - - if (this.#entries.some(e => e.key === key)) { - throw new Error( - 'Legacy externalAccess token was declared more than once', - ); - } - - this.#entries.push({ - key, - result: { - subject, - allAccessRestrictions, - }, - }); - } - - async verifyToken(token: string) { - // First do a duck typing check to see if it remotely looks like a legacy token - try { - // We do a fair amount of checking upfront here. Since we aren't certain - // that it's even the right type of key that we're looking at, we can't - // defer eg the alg check to jwtVerify, because it won't be possible to - // discern different reasons for key verification failures from each other - // easily - const { alg } = decodeProtectedHeader(token); - if (alg !== 'HS256') { - return undefined; - } - const { sub, aud } = decodeJwt(token); - if (sub !== 'backstage-server' || aud) { - return undefined; - } - } catch (e) { - // Doesn't look like a jwt at all - return undefined; - } - - for (const { key, result } of this.#entries) { try { - await jwtVerify(token, key); - return result; - } catch (e) { - if (e.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') { - throw e; - } - // Otherwise continue to try the next key + return { + key: base64url.decode(secret), + subject, + }; + } catch { + throw new Error('Illegal secret, must be a valid base64 string'); } - } + }, - // None of the signing keys matched - return undefined; - } -} + async verifyToken(token: string, context: LegacyTokenHandlerContext) { + // First do a duck typing check to see if it remotely looks like a legacy token + try { + // We do a fair amount of checking upfront here. Since we aren't certain + // that it's even the right type of key that we're looking at, we can't + // defer eg the alg check to jwtVerify, because it won't be possible to + // discern different reasons for key verification failures from each other + // easily + const { alg } = decodeProtectedHeader(token); + if (alg !== 'HS256') { + return undefined; + } + const { sub, aud } = decodeJwt(token); + if (sub !== 'backstage-server' || aud) { + return undefined; + } + } catch (e) { + // Doesn't look like a jwt at all + return undefined; + } -function isLegacy( - config: Config | LegacyConfigWrapper, -): config is LegacyConfigWrapper { - return (config as LegacyConfigWrapper).legacy === true; -} + try { + await jwtVerify(token, context.key); + return { + subject: context.subject, + }; + } catch (error) { + if (error.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') { + throw error; + } + } + + // None of the signing keys matched + return undefined; + }, + }); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/static.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/static.test.ts index cf1c1a46ac..10e78d2dbf 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/static.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/static.test.ts @@ -15,148 +15,146 @@ */ import { ConfigReader } from '@backstage/config'; -import { StaticTokenHandler } from './static'; +import { staticTokenHandler } from './static'; describe('StaticTokenHandler', () => { it('accepts any of the added list of tokens', async () => { - const configs = [ - new ConfigReader({ - options: { token: 'abcabcabc', subject: 'one' }, - accessRestrictions: [{ plugin: 'scaffolder' }], + const context1 = staticTokenHandler.initialize({ + options: new ConfigReader({ + token: 'abcabcabc', + subject: 'one', }), - new ConfigReader({ - options: { token: 'defdefdef', subject: 'two' }, - accessRestrictions: [ - { plugin: 'catalog', permission: 'catalog.entity.read' }, - ], + }); + const context2 = staticTokenHandler.initialize({ + options: new ConfigReader({ + token: 'defdefdef', + subject: 'two', }), - ]; - const handler = new StaticTokenHandler(configs); - const accessRestrictionsOne = new Map(Object.entries({ scaffolder: {} })); - const accessRestrictionsTwo = new Map( - Object.entries({ - catalog: { - permissionNames: ['catalog.entity.read'], - }, - }), - ); + }); - await expect(handler.verifyToken('abcabcabc')).resolves.toEqual({ + await expect( + staticTokenHandler.verifyToken('abcabcabc', context1), + ).resolves.toEqual({ subject: 'one', - allAccessRestrictions: accessRestrictionsOne, }); - await expect(handler.verifyToken('defdefdef')).resolves.toEqual({ + await expect( + staticTokenHandler.verifyToken('defdefdef', context2), + ).resolves.toEqual({ subject: 'two', - allAccessRestrictions: accessRestrictionsTwo, }); - await expect(handler.verifyToken('ghighighi')).resolves.toBeUndefined(); + await expect( + staticTokenHandler.verifyToken('ghighighi', context1), + ).resolves.toBeUndefined(); }); it('gracefully handles no added tokens', async () => { - const handler = new StaticTokenHandler([]); - await expect(handler.verifyToken('ghi')).resolves.toBeUndefined(); + await expect( + staticTokenHandler.verifyToken('ghi', {} as any), + ).resolves.toBeUndefined(); }); it('rejects bad config', () => { - expect( - () => - new StaticTokenHandler([ - new ConfigReader({ options: { _missingtoken: true, subject: 'ok' } }), - ]), + expect(() => + staticTokenHandler.initialize({ + options: new ConfigReader({ _missingtoken: true, subject: 'ok' }), + }), ).toThrowErrorMatchingInlineSnapshot( - `"Missing required config value at 'options.token' in 'mock-config'"`, + `"Missing required config value at 'token' in 'mock-config'"`, ); - expect( - () => - new StaticTokenHandler([ - new ConfigReader({ options: { token: '', subject: 'ok' } }), - ]), + expect(() => + staticTokenHandler.initialize({ + options: new ConfigReader({ token: '', subject: 'ok' }), + }), ).toThrowErrorMatchingInlineSnapshot( - `"Invalid type in config for key 'options.token' in 'mock-config', got empty-string, wanted string"`, + `"Invalid type in config for key 'token' in 'mock-config', got empty-string, wanted string"`, ); - expect( - () => - new StaticTokenHandler([ - new ConfigReader({ options: { token: 'has spaces', subject: 'ok' } }), - ]), + expect(() => + staticTokenHandler.initialize({ + options: new ConfigReader({ + token: 'has spaces', + subject: 'ok', + }), + }), ).toThrowErrorMatchingInlineSnapshot( `"Illegal token, must be a set of non-space characters"`, ); - expect( - () => - new StaticTokenHandler([ - new ConfigReader({ - options: { - token: 'hasnewlinebutislongenough\n', - subject: 'ok', - }, - }), - ]), + expect(() => + staticTokenHandler.initialize({ + options: new ConfigReader({ + token: 'hasnewlinebutislongenough\n', + subject: 'ok', + }), + }), ).toThrowErrorMatchingInlineSnapshot( `"Illegal token, must be a set of non-space characters"`, ); - expect( - () => - new StaticTokenHandler([ - new ConfigReader({ options: { token: 'short', subject: 'ok' } }), - ]), + expect(() => + staticTokenHandler.initialize({ + options: new ConfigReader({ + token: 'short', + subject: 'ok', + }), + }), ).toThrowErrorMatchingInlineSnapshot( `"Illegal token, must be at least 8 characters length"`, ); - expect( - () => - new StaticTokenHandler([ - new ConfigReader({ options: { token: 3, subject: 'ok' } }), - ]), + expect(() => + staticTokenHandler.initialize({ + options: new ConfigReader({ token: 3, subject: 'ok' }), + }), ).toThrowErrorMatchingInlineSnapshot( - `"Invalid type in config for key 'options.token' in 'mock-config', got number, wanted string"`, + `"Invalid type in config for key 'token' in 'mock-config', got number, wanted string"`, ); - expect( - () => - new StaticTokenHandler([ - new ConfigReader({ - options: { token: 'validtoken', _missingsubject: true }, - }), - ]), + expect(() => + staticTokenHandler.initialize({ + options: new ConfigReader({ + token: 'validtoken', + _missingsubject: true, + }), + }), ).toThrowErrorMatchingInlineSnapshot( - `"Missing required config value at 'options.subject' in 'mock-config'"`, + `"Missing required config value at 'subject' in 'mock-config'"`, ); - expect( - () => - new StaticTokenHandler([ - new ConfigReader({ options: { token: 'validtoken', subject: '' } }), - ]), + expect(() => + staticTokenHandler.initialize({ + options: new ConfigReader({ + token: 'validtoken', + subject: '', + }), + }), ).toThrowErrorMatchingInlineSnapshot( - `"Invalid type in config for key 'options.subject' in 'mock-config', got empty-string, wanted string"`, + `"Invalid type in config for key 'subject' in 'mock-config', got empty-string, wanted string"`, ); - expect( - () => - new StaticTokenHandler([ - new ConfigReader({ - options: { token: 'validtoken', subject: 'has spaces' }, - }), - ]), + expect(() => + staticTokenHandler.initialize({ + options: new ConfigReader({ + token: 'validtoken', + subject: 'has spaces', + }), + }), ).toThrowErrorMatchingInlineSnapshot( `"Illegal subject, must be a set of non-space characters"`, ); - expect( - () => - new StaticTokenHandler([ - new ConfigReader({ - options: { token: 'validtoken', subject: 'hasnewline\n' }, - }), - ]), + expect(() => + staticTokenHandler.initialize({ + options: new ConfigReader({ + token: 'validtoken', + subject: 'hasnewline\n', + }), + }), ).toThrowErrorMatchingInlineSnapshot( `"Illegal subject, must be a set of non-space characters"`, ); - expect( - () => - new StaticTokenHandler([ - new ConfigReader({ options: { token: 'validtoken', subject: 3 } }), - ]), + expect(() => + staticTokenHandler.initialize({ + options: new ConfigReader({ + token: 'validtoken', + subject: 3, + }), + }), ).toThrowErrorMatchingInlineSnapshot( - `"Invalid type in config for key 'options.subject' in 'mock-config', got number, wanted string"`, + `"Invalid type in config for key 'subject' in 'mock-config', got number, wanted string"`, ); }); }); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/static.ts b/packages/backend-defaults/src/entrypoints/auth/external/static.ts index 7764569aca..fe7b9995a4 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/static.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/static.ts @@ -15,34 +15,18 @@ */ import { Config } from '@backstage/config'; -import { readAccessRestrictionsFromConfig } from './helpers'; -import { AccessRestrictionsMap, TokenHandler } from './types'; +import { createExternalTokenHandler } from './helpers'; const MIN_TOKEN_LENGTH = 8; -/** - * Handles `type: static` access. - * - * @internal - */ -export class StaticTokenHandler implements TokenHandler { - #entries = new Map< - string, - { - subject: string; - allAccessRestrictions?: AccessRestrictionsMap; - } - >(); - - constructor(configs: Config[]) { - for (const config of configs) { - this.add(config); - } - } - private add(config: Config) { - const token = config.getString('options.token'); - const subject = config.getString('options.subject'); - const allAccessRestrictions = readAccessRestrictionsFromConfig(config); +export const staticTokenHandler = createExternalTokenHandler<{ + token: string; + subject: string; +}>({ + type: 'static', + initialize(ctx: { options: Config }): { token: string; subject: string } { + const token = ctx.options.getString('token'); + const subject = ctx.options.getString('subject'); if (!token.match(/^\S+$/)) { throw new Error('Illegal token, must be a set of non-space characters'); @@ -52,17 +36,64 @@ export class StaticTokenHandler implements TokenHandler { ); } else if (!subject.match(/^\S+$/)) { throw new Error('Illegal subject, must be a set of non-space characters'); - } else if (this.#entries.has(token)) { - throw new Error( - 'Static externalAccess token was declared more than once', - ); } - this.#entries.set(token, { subject, allAccessRestrictions }); - return this; - } + return { token, subject }; + }, + async verifyToken( + token: string, + context: { token: string; subject: string }, + ) { + if (token === context.token) { + return { subject: context.subject }; + } + return undefined; + }, +}); - async verifyToken(token: string) { - return this.#entries.get(token); - } -} +/** + * Handles `type: static` access. + * + * @internal + */ +// export class StaticTokenHandler implements TokenHandler { +// #entries = new Map< +// string, +// { +// subject: string; +// allAccessRestrictions?: AccessRestrictionsMap; +// } +// >(); + +// constructor(configs: Config[]) { +// for (const config of configs) { +// this.add(config); +// } +// } +// private add(config: Config) { +// const token = config.getString('options.token'); +// const subject = config.getString('options.subject'); +// const allAccessRestrictions = readAccessRestrictionsFromConfig(config); + +// if (!token.match(/^\S+$/)) { +// throw new Error('Illegal token, must be a set of non-space characters'); +// } else if (token.length < MIN_TOKEN_LENGTH) { +// throw new Error( +// `Illegal token, must be at least ${MIN_TOKEN_LENGTH} characters length`, +// ); +// } else if (!subject.match(/^\S+$/)) { +// throw new Error('Illegal subject, must be a set of non-space characters'); +// } else if (this.#entries.has(token)) { +// throw new Error( +// 'Static externalAccess token was declared more than once', +// ); +// } + +// this.#entries.set(token, { subject, allAccessRestrictions }); +// return this; +// } + +// async verifyToken(token: string) { +// return this.#entries.get(token); +// } +// } diff --git a/packages/backend-defaults/src/entrypoints/auth/external/types.ts b/packages/backend-defaults/src/entrypoints/auth/external/types.ts index 5e26b66da5..6ce1ca13b6 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/types.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/types.ts @@ -15,6 +15,7 @@ */ import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api'; +import type { Config } from '@backstage/config'; /** * @public @@ -29,27 +30,24 @@ export type AccessRestrictionsMap = Map< * This interface is used to handle external tokens. * It is used by the auth service to verify tokens and extract the subject. */ -export interface TokenHandler { - verifyToken(token: string): Promise< - | { - subject: string; - allAccessRestrictions?: AccessRestrictionsMap; - } - | undefined - >; +export interface ExternalTokenHandler { + type: string; + initialize(ctx: { options: Config }): TContext; + verifyToken( + token: string, + ctx: TContext, + ): Promise<{ subject: string } | undefined>; } -/** - * @public - * This interface is used to handle external tokens. - */ -export interface TokenTypeHandler { - type: string; - /** - * A factory function that takes all token configuration for a given type - * and returns a TokenHandler or an array of TokenHandlers. - */ - factory: ( - configs: import('@backstage/config').Config[], - ) => TokenHandler | TokenHandler[]; -} +// /** +// * @public +// * This interface is used to handle external tokens. +// */ +// export interface TokenTypeHandler { +// type: string; +// /** +// * A factory function that takes all token configuration for a given type +// * and returns a TokenHandler or an array of TokenHandlers. +// */ +// factory: (configs: Config[]) => TokenHandler | TokenHandler[]; +// } diff --git a/packages/backend-defaults/src/entrypoints/auth/index.ts b/packages/backend-defaults/src/entrypoints/auth/index.ts index 335fc2f62c..e205c8b566 100644 --- a/packages/backend-defaults/src/entrypoints/auth/index.ts +++ b/packages/backend-defaults/src/entrypoints/auth/index.ts @@ -19,9 +19,11 @@ export { pluginTokenHandlerDecoratorServiceRef, } from './authServiceFactory'; -export { externalTokenTypeHandlersRef } from './external/ExternalTokenHandler'; +export { createExternalTokenHandler } from './external/helpers'; -export type { TokenHandler, TokenTypeHandler } from './external/types'; +export { externalTokenTypeHandlersRef } from './external/ExternalAuthTokenHandler'; + +export type { ExternalTokenHandler } from './external/types'; export type { AccessRestrictionsMap } from './external/types'; export type { PluginTokenHandler } from './plugin/PluginTokenHandler'; From 6361c0c00fe8716c5b9c2635dbd366aa49f8fb86 Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Sat, 6 Sep 2025 19:11:23 +0200 Subject: [PATCH 09/15] fix: rename the Internal token handler Signed-off-by: Juan Pablo Garcia Ripa --- .../src/entrypoints/auth/DefaultAuthService.ts | 4 ++-- .../src/entrypoints/auth/authServiceFactory.ts | 4 ++-- .../auth/external/ExternalAuthTokenHandler.test.ts | 14 +++++++------- .../auth/external/ExternalAuthTokenHandler.ts | 6 +++--- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/auth/DefaultAuthService.ts b/packages/backend-defaults/src/entrypoints/auth/DefaultAuthService.ts index c2c4a064a3..dba4f70dec 100644 --- a/packages/backend-defaults/src/entrypoints/auth/DefaultAuthService.ts +++ b/packages/backend-defaults/src/entrypoints/auth/DefaultAuthService.ts @@ -25,7 +25,7 @@ import { import { AuthenticationError } from '@backstage/errors'; import { JsonObject } from '@backstage/types'; import { decodeJwt } from 'jose'; -import { ExternalAuthTokenManager } from './external/export class ExternalAuthTokenManager {'; +import { ExternalAuthTokenHandler } from './external/ExternalAuthTokenHandler'; import { createCredentialsWithNonePrincipal, createCredentialsWithServicePrincipal, @@ -41,7 +41,7 @@ export class DefaultAuthService implements AuthService { constructor( private readonly userTokenHandler: UserTokenHandler, private readonly pluginTokenHandler: PluginTokenHandler, - private readonly externalTokenHandler: ExternalAuthTokenManager, + private readonly externalTokenHandler: ExternalAuthTokenHandler, private readonly pluginId: string, private readonly disableDefaultAuthPolicy: boolean, private readonly pluginKeySource: PluginKeySource, diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts index 34fc3b51ef..0f8c5ff75d 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts @@ -21,7 +21,7 @@ import { } from '@backstage/backend-plugin-api'; import { DefaultAuthService } from './DefaultAuthService'; import { - ExternalAuthTokenManager, + ExternalAuthTokenHandler, externalTokenTypeHandlersRef, } from './external/ExternalAuthTokenHandler'; import { @@ -107,7 +107,7 @@ export const authServiceFactory = createServiceFactory({ }), ); - const externalTokens = ExternalAuthTokenManager.create({ + const externalTokens = ExternalAuthTokenHandler.create({ ownPluginId: plugin.getId(), config, logger, diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts index d97875d228..491de30ac4 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts @@ -15,7 +15,7 @@ */ import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api'; -import { ExternalAuthTokenManager } from './ExternalAuthTokenHandler'; +import { ExternalAuthTokenHandler } from './ExternalAuthTokenHandler'; import { createExternalTokenHandler } from './helpers'; import { AccessRestrictionsMap, ExternalTokenHandler } from './types'; import { @@ -108,7 +108,7 @@ describe('ExternalTokenHandler', () => { }), ); - const plugin1 = new ExternalAuthTokenManager('plugin1', [ + const plugin1 = new ExternalAuthTokenHandler('plugin1', [ { context: undefined, handler: handler1, @@ -119,7 +119,7 @@ describe('ExternalTokenHandler', () => { allAccessRestrictions: accessRestrictions, }, ]); - const plugin2 = new ExternalAuthTokenManager('plugin2', [ + const plugin2 = new ExternalAuthTokenHandler('plugin2', [ { context: undefined, handler: handler1, @@ -160,7 +160,7 @@ describe('ExternalTokenHandler', () => { ), ); - const handler = ExternalAuthTokenManager.create({ + const handler = ExternalAuthTokenHandler.create({ ownPluginId: 'catalog', logger: mockServices.logger.mock(), config: mockServices.rootConfig({ @@ -252,7 +252,7 @@ describe('ExternalTokenHandler', () => { const verifyMock = jest.fn().mockResolvedValue({}); const initializeMock = jest.fn().mockReturnValue({ context: 'a' }); - const handler = ExternalAuthTokenManager.create({ + const handler = ExternalAuthTokenHandler.create({ ownPluginId: 'catalog', logger: mockServices.logger.mock(), config: mockServices.rootConfig({ @@ -305,7 +305,7 @@ describe('ExternalTokenHandler', () => { }); it('should fail if config contains types not declared', async () => { const createHandler = () => - ExternalAuthTokenManager.create({ + ExternalAuthTokenHandler.create({ ownPluginId: 'catalog', logger: mockServices.logger.mock(), config: mockServices.rootConfig({ @@ -338,7 +338,7 @@ describe('ExternalTokenHandler', () => { it('should show valid custom types in errors', async () => { const createHandler = () => - ExternalAuthTokenManager.create({ + ExternalAuthTokenHandler.create({ ownPluginId: 'catalog', logger: mockServices.logger.mock(), config: mockServices.rootConfig({ diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts index bf57ad42f4..17541ff384 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts @@ -60,13 +60,13 @@ type ContextMapEntry = { * * @internal */ -export class ExternalAuthTokenManager { +export class ExternalAuthTokenHandler { static create(options: { ownPluginId: string; config: RootConfigService; logger: LoggerService; externalTokenHandlers?: ExternalTokenHandler[]; - }): ExternalAuthTokenManager { + }): ExternalAuthTokenHandler { const { ownPluginId, config, @@ -110,7 +110,7 @@ export class ExternalAuthTokenManager { ); } - return new ExternalAuthTokenManager(ownPluginId, contexts); + return new ExternalAuthTokenHandler(ownPluginId, contexts); } constructor( From 968b2f606ed3062f4bb08fd37b51c07e83019aea Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Sat, 6 Sep 2025 19:48:23 +0200 Subject: [PATCH 10/15] fix: api-reports Signed-off-by: Juan Pablo Garcia Ripa --- packages/backend-defaults/report-auth.api.md | 46 ++++++++++--------- .../src/entrypoints/auth/external/helpers.ts | 28 +++++++++++ 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/packages/backend-defaults/report-auth.api.md b/packages/backend-defaults/report-auth.api.md index 176bdd16de..0164fa13c1 100644 --- a/packages/backend-defaults/report-auth.api.md +++ b/packages/backend-defaults/report-auth.api.md @@ -5,7 +5,7 @@ ```ts import { AuthService } from '@backstage/backend-plugin-api'; import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api'; -import { Config } from '@backstage/config'; +import type { Config } from '@backstage/config'; import { ServiceFactory } from '@backstage/backend-plugin-api'; import { ServiceRef } from '@backstage/backend-plugin-api'; @@ -22,9 +22,32 @@ export const authServiceFactory: ServiceFactory< 'singleton' >; +// @public +export function createExternalTokenHandler( + handler: ExternalTokenHandler, +): ExternalTokenHandler; + +// @public +export interface ExternalTokenHandler { + // (undocumented) + initialize(ctx: { options: Config }): TContext; + // (undocumented) + type: string; + // (undocumented) + verifyToken( + token: string, + ctx: TContext, + ): Promise< + | { + subject: string; + } + | undefined + >; +} + // @public export const externalTokenTypeHandlersRef: ServiceRef< - TokenTypeHandler, + ExternalTokenHandler, 'plugin', 'multiton' >; @@ -59,24 +82,5 @@ export const pluginTokenHandlerDecoratorServiceRef: ServiceRef< 'singleton' >; -// @public -export interface ExternalTokenHandler { - // (undocumented) - verifyToken(token: string): Promise< - | { - subject: string; - allAccessRestrictions?: AccessRestrictionsMap; - } - | undefined - >; -} - -// @public -export interface TokenTypeHandler { - factory: (configs: Config[]) => TokenHandler | TokenHandler[]; - // (undocumented) - type: string; -} - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend-defaults/src/entrypoints/auth/external/helpers.ts b/packages/backend-defaults/src/entrypoints/auth/external/helpers.ts index 296ec2e72b..94d289bd7c 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/helpers.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/helpers.ts @@ -148,6 +148,34 @@ function readPermissionAttributes(externalAccessEntryConfig: Config) { return Object.keys(result).length ? result : undefined; } +/** + * Creates an external token handler with the provided implementation. + * + * This helper function simplifies the creation of external token handlers by + * providing type safety and a consistent API. External token handlers are used + * to validate tokens from external systems that need to authenticate with Backstage. + * + * See {@link https://backstage.io/docs/auth/service-to-service-auth#adding-custom-externaltokenhandler | the service-to-service auth docs} + * for more information about implementing custom external token handlers. + * + * @public + * @param handler - The external token handler implementation with type, initialize, and verifyToken methods + * @returns The same handler instance, typed as ExternalTokenHandler + * + * @example + * ```ts + * const customHandler = createExternalTokenHandler({ + * type: 'custom', + * initialize({ options }) { + * return { apiKey: options.getString('apiKey') }; + * }, + * async verifyToken(token, context) { + * // Custom validation logic here + * return { subject: 'custom:user' }; + * }, + * }); + * ``` + */ export function createExternalTokenHandler( handler: ExternalTokenHandler, ): ExternalTokenHandler { From 6ff5452d3c37b21a9390c06bb38a6f03707e5b41 Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Mon, 8 Sep 2025 09:52:12 +0200 Subject: [PATCH 11/15] fix: correct grammar Co-authored-by: Peter Macdonald Signed-off-by: Juan Pablo Garcia Ripa --- docs/backend-system/architecture/03-services.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/backend-system/architecture/03-services.md b/docs/backend-system/architecture/03-services.md index f6722496e5..022aab202b 100644 --- a/docs/backend-system/architecture/03-services.md +++ b/docs/backend-system/architecture/03-services.md @@ -225,8 +225,8 @@ This allows you to provide more advanced options for the service implementation ## Multiton -By default the service reference will point to a singleton instance of the service. This mean if a new service factory uses this reference will override the previous one. This is the most common use-case, but in some cases you may want to have multiple instances of the same service. -For some services, is desirable to extend the functionality instead of overriding it. For example, some services could have many handler to address a specific event, and you may want to add a new handler instead of overriding the previous one. In this case, you can use the `multiton` option when creating the service reference: +By default the service reference will point to a singleton instance of the service. This mean if a new service factory uses this reference it will override the previous one. This is the most common use-case, but in some cases you may want to have multiple instances of the same service. +For some services, it is desirable to extend the functionality instead of overriding it. For example, some services could have many handlers to address specific events, and you may want to add a new handler instead of overriding the previous one. In this case, you can use the `multiton` option when creating the service reference: ```ts // example-service-ref.ts From 823ed49a43b7b6f22344f28ce729f8b16c373809 Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Thu, 11 Sep 2025 16:33:45 +0200 Subject: [PATCH 12/15] clean up Signed-off-by: Juan Pablo Garcia Ripa --- .../entrypoints/auth/external/legacy.test.ts | 30 ------------ .../src/entrypoints/auth/external/static.ts | 47 ------------------- 2 files changed, 77 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts index 43160b46da..a278e5766c 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts @@ -235,35 +235,5 @@ describe('LegacyTokenHandler', () => { ).toThrowErrorMatchingInlineSnapshot( `"Invalid type in config for key 'subject' in 'mock-config', got number, wanted string"`, ); - - // // old style add - // expect(() => - // handler.addOld(new ConfigReader({ secret: 'b2s=' })), - // ).not.toThrow(); - // expect(() => - // handler.addOld(new ConfigReader({ _missingsecret: true })), - // ).toThrowErrorMatchingInlineSnapshot( - // `"Missing required config value at 'secret' in 'mock-config'"`, - // ); - // expect(() => - // handler.addOld(new ConfigReader({ secret: '' })), - // ).toThrowErrorMatchingInlineSnapshot( - // `"Invalid type in config for key 'secret' in 'mock-config', got empty-string, wanted string"`, - // ); - // expect(() => - // handler.addOld(new ConfigReader({ secret: 'has spaces' })), - // ).toThrowErrorMatchingInlineSnapshot( - // `"Illegal secret, must be a valid base64 string"`, - // ); - // expect(() => - // handler.addOld(new ConfigReader({ secret: 'hasnewline\n' })), - // ).toThrowErrorMatchingInlineSnapshot( - // `"Illegal secret, must be a valid base64 string"`, - // ); - // expect(() => - // handler.addOld(new ConfigReader({ secret: 3 })), - // ).toThrowErrorMatchingInlineSnapshot( - // `"Invalid type in config for key 'secret' in 'mock-config', got number, wanted string"`, - // ); }); }); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/static.ts b/packages/backend-defaults/src/entrypoints/auth/external/static.ts index fe7b9995a4..502150d5a8 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/static.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/static.ts @@ -50,50 +50,3 @@ export const staticTokenHandler = createExternalTokenHandler<{ return undefined; }, }); - -/** - * Handles `type: static` access. - * - * @internal - */ -// export class StaticTokenHandler implements TokenHandler { -// #entries = new Map< -// string, -// { -// subject: string; -// allAccessRestrictions?: AccessRestrictionsMap; -// } -// >(); - -// constructor(configs: Config[]) { -// for (const config of configs) { -// this.add(config); -// } -// } -// private add(config: Config) { -// const token = config.getString('options.token'); -// const subject = config.getString('options.subject'); -// const allAccessRestrictions = readAccessRestrictionsFromConfig(config); - -// if (!token.match(/^\S+$/)) { -// throw new Error('Illegal token, must be a set of non-space characters'); -// } else if (token.length < MIN_TOKEN_LENGTH) { -// throw new Error( -// `Illegal token, must be at least ${MIN_TOKEN_LENGTH} characters length`, -// ); -// } else if (!subject.match(/^\S+$/)) { -// throw new Error('Illegal subject, must be a set of non-space characters'); -// } else if (this.#entries.has(token)) { -// throw new Error( -// 'Static externalAccess token was declared more than once', -// ); -// } - -// this.#entries.set(token, { subject, allAccessRestrictions }); -// return this; -// } - -// async verifyToken(token: string) { -// return this.#entries.get(token); -// } -// } From 818c835a10e54e1889edcb450c9a11311367139d Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Mon, 15 Sep 2025 22:40:23 +0200 Subject: [PATCH 13/15] bring the legacy config back Signed-off-by: Juan Pablo Garcia Ripa --- .changeset/thirty-rules-press.md | 21 --- docs/auth/service-to-service-auth.md | 36 +----- packages/backend-defaults/report-auth.api.md | 9 +- .../auth/authServiceFactory.test.ts | 8 +- .../entrypoints/auth/authServiceFactory.ts | 4 +- .../external/ExternalAuthTokenHandler.test.ts | 51 ++++++++ .../auth/external/ExternalAuthTokenHandler.ts | 24 +++- .../entrypoints/auth/external/legacy.test.ts | 70 ++++++++++ .../src/entrypoints/auth/external/legacy.ts | 120 ++++++++++-------- .../src/entrypoints/auth/external/types.ts | 16 --- .../src/entrypoints/auth/index.ts | 3 +- 11 files changed, 219 insertions(+), 143 deletions(-) diff --git a/.changeset/thirty-rules-press.md b/.changeset/thirty-rules-press.md index 760780eb84..d9968317cb 100644 --- a/.changeset/thirty-rules-press.md +++ b/.changeset/thirty-rules-press.md @@ -3,24 +3,3 @@ --- Add a new `externalTokenHandlersServiceRef` to allow custom external token validations - -BREAKING CHANGE: The `backend.auth.keys` config has been removed. Please migrate to the new `backend.auth.externalAccess` config as described in the documentation: https://backstage.io/docs/auth/service-to-service-auth - -**Migration Example:** - -```yaml -# ❌ Old format (no longer supported) -backend: - auth: - keys: - - secret: your-secret-key - -# ✅ New format -backend: - auth: - externalAccess: - - type: static - options: - token: your-secret-key - subject: external:backstage-plugin # this is the current default for old keys -``` diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index 4ba094068e..1423fa92f7 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -255,16 +255,10 @@ backend: subject: legacy-scaffolder ``` -:::warning -The old style `backend.auth.keys` config is **no longer supported** and has been removed. -If you are still using this configuration, you must migrate to the new `backend.auth.externalAccess` format above. +The old style keys config is also supported as an alternative, but please +consider using the new style above instead: -You'll see an error like `"Unknown key 'backend.auth.keys'"` during Backstage startup if you haven't migrated yet. -::: - -To migrate from the old config format: - -```yaml title="❌ Old format (no longer supported)" +```yaml title="in e.g. app-config.production.yaml" backend: auth: keys: @@ -272,22 +266,6 @@ backend: - secret: my-secret-key-scaffolder ``` -Convert to: - -```yaml title="✅ New format" -backend: - auth: - externalAccess: - - type: legacy - options: - secret: my-secret-key-catalog - subject: external:backstage-plugin - - type: legacy - options: - secret: my-secret-key-scaffolder - subject: external:backstage-plugin -``` - The secrets must be any base64-encoded random data, but for security reasons should be sufficiently long so as not to be easy to guess by brute force. You can for example generate them on the command line: @@ -436,7 +414,7 @@ Each entry has one or more of the following fields: ## Adding custom or logic for validation and issuing of tokens -The `pluginTokenHandlerDecoratorServiceRef` and `externalTokenTypeHandlersRef` can be used to extend the existing token handler without having to re-implement the entire `AuthService` implementation. +The `pluginTokenHandlerDecoratorServiceRef` and `externalTokenHandlersServiceRef` can be used to extend the existing token handler without having to re-implement the entire `AuthService` implementation. This is particularly useful when you want to add additional logic to the handler, such as logging or metrics or custom token validation. ### PluginTokenHandler decoration @@ -468,7 +446,7 @@ const decoratedPluginTokenHandler = createServiceFactory({ ### Adding custom ExternalTokenHandler -The `externalTokenTypeHandlersRef` can be used to add custom external token handlers to the default implementation. +The `externalTokenHandlersServiceRef` can be used to add custom external token handlers to the default implementation. Your service factory must return an object with a `type` property that matches the token type in your configuration (e.g., 'custom', 'api-key'). When Backstage encounters tokens of this type, it calls your `initialize` method with all the configuration entries that match this type. Your factory can return either a single token handler or an array of handlers to process and validate these tokens. @@ -503,13 +481,13 @@ And we can implement the custom token handler like this: ```ts import { ExternalTokenHandler, - externalTokenTypeHandlersRef, + externalTokenHandlersServiceRef, createExternalTokenHandler, } from '@backstage/backend-defaults/auth'; import { createServiceFactory } from '@backstage/backend-plugin-api'; const customExternalTokenHandlers = createServiceFactory({ - service: externalTokenTypeHandlersRef, + service: externalTokenHandlersServiceRef, deps: {}, async factory() { return createExternalTokenHandler({ diff --git a/packages/backend-defaults/report-auth.api.md b/packages/backend-defaults/report-auth.api.md index 0164fa13c1..2fc0f16452 100644 --- a/packages/backend-defaults/report-auth.api.md +++ b/packages/backend-defaults/report-auth.api.md @@ -4,17 +4,10 @@ ```ts import { AuthService } from '@backstage/backend-plugin-api'; -import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api'; import type { Config } from '@backstage/config'; import { ServiceFactory } from '@backstage/backend-plugin-api'; import { ServiceRef } from '@backstage/backend-plugin-api'; -// @public (undocumented) -export type AccessRestrictionsMap = Map< - string, // plugin ID - BackstagePrincipalAccessRestrictions ->; - // @public export const authServiceFactory: ServiceFactory< AuthService, @@ -46,7 +39,7 @@ export interface ExternalTokenHandler { } // @public -export const externalTokenTypeHandlersRef: ServiceRef< +export const externalTokenHandlersServiceRef: ServiceRef< ExternalTokenHandler, 'plugin', 'multiton' diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts index b6d5beefa2..b70aedfb9a 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts @@ -31,7 +31,7 @@ import { toInternalBackstageCredentials } from './helpers'; import { PluginTokenHandler } from './plugin/PluginTokenHandler'; import { createServiceFactory } from '@backstage/backend-plugin-api'; -import { externalTokenTypeHandlersRef } from './external/ExternalAuthTokenHandler'; +import { externalTokenHandlersServiceRef } from './external/ExternalAuthTokenHandler'; import { createExternalTokenHandler } from './external/helpers'; const server = setupServer(); @@ -44,7 +44,7 @@ const mockDeps = [ backend: { baseUrl: 'http://localhost', auth: { - // keys: [{ secret: 'abc' }], + keys: [{ secret: 'abc' }], externalAccess: [ { type: 'static', @@ -468,7 +468,7 @@ describe('authServiceFactory', () => { backend: { baseUrl: 'http://localhost', auth: { - // keys: [{ secret: 'abc' }w], + keys: [{ secret: 'abc' }], externalAccess: [ { type: 'static', @@ -521,7 +521,7 @@ describe('authServiceFactory', () => { }); const customPluginTokenHandler = createServiceFactory({ - service: externalTokenTypeHandlersRef, + service: externalTokenHandlersServiceRef, deps: {}, async factory() { return customExternalTokenHandler; diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts index 0f8c5ff75d..ee171aef25 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts @@ -22,7 +22,7 @@ import { import { DefaultAuthService } from './DefaultAuthService'; import { ExternalAuthTokenHandler, - externalTokenTypeHandlersRef, + externalTokenHandlersServiceRef, } from './external/ExternalAuthTokenHandler'; import { DefaultPluginTokenHandler, @@ -67,7 +67,7 @@ export const authServiceFactory = createServiceFactory({ plugin: coreServices.pluginMetadata, database: coreServices.database, pluginTokenHandlerDecorator: pluginTokenHandlerDecoratorServiceRef, - externalTokenHandlers: externalTokenTypeHandlersRef, + externalTokenHandlers: externalTokenHandlersServiceRef, }, async factory({ config, diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts index 491de30ac4..f315b43314 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts @@ -233,6 +233,57 @@ describe('ExternalTokenHandler', () => { accessRestrictions: { permissionNames: ['catalog.entity.read'] }, }); }); + it('successfully uses legacy configs', async () => { + const legacyKey = randomBytes(24); + const factory = new FakeTokenFactory({ + issuer: 'my-company', + keyDurationSeconds: 100, + }); + + server.use( + rest.get( + 'https://example.com/.well-known/jwks.json', + async (_, res, ctx) => { + const keys = await factory.listPublicKeys(); + return res(ctx.json(keys)); + }, + ), + ); + + const logger = mockServices.logger.mock(); + const handler = ExternalAuthTokenHandler.create({ + ownPluginId: 'catalog', + logger, + config: mockServices.rootConfig({ + data: { + backend: { + auth: { + keys: [ + { + secret: legacyKey.toString('base64'), + }, + ], + }, + }, + }, + }), + }); + + expect(logger.warn).toHaveBeenCalledWith( + `DEPRECATION WARNING: The backend.auth.keys config has been replaced by backend.auth.externalAccess, see https://backstage.io/docs/auth/service-to-service-auth`, + ); + + const legacyToken = await new SignJWT({ + sub: 'backstage-server', + exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(), + }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(legacyKey); + + await expect(handler.verifyToken(legacyToken)).resolves.toEqual({ + subject: 'external:backstage-plugin', + }); + }); it('successfully uses custom token handlers', async () => { const factory = new FakeTokenFactory({ issuer: 'my-company', diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts index 17541ff384..4b2cbb3e5a 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts @@ -33,14 +33,13 @@ let loggedDeprecationWarning = false; /** * @public - * This service is used to decorate the default plugin token handler with custom logic. + * This service is used to add custom handlers for external token. */ -export const externalTokenTypeHandlersRef = createServiceRef< +export const externalTokenHandlersServiceRef = createServiceRef< ExternalTokenHandler >({ id: 'core.auth.externalTokenHandlers', multiton: true, - // defaultFactory // :pepe-think: seems like is not possible to use defaultFactory with multiton }); const defaultHandlers: ExternalTokenHandler[] = [ @@ -54,6 +53,7 @@ type ContextMapEntry = { handler: ExternalTokenHandler; allAccessRestrictions?: AccessRestrictionsMap; }; + /** * Handles all types of external caller token types (i.e. not Backstage user * tokens, nor Backstage backend plugin tokens). @@ -71,6 +71,7 @@ export class ExternalAuthTokenHandler { ownPluginId, config, externalTokenHandlers: customHandlers, + logger, } = options; const handlersTypes = [...defaultHandlers, ...(customHandlers ?? [])]; @@ -104,12 +105,23 @@ export class ExternalAuthTokenHandler { if (legacyConfigs.length && !loggedDeprecationWarning) { loggedDeprecationWarning = true; - // :pepe-think: this message was here for more than a year, and replacing this simplifies things a lot - throw new Error( - `The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`, + + logger.warn( + `DEPRECATION WARNING: The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`, ); } + for (const legacyConfig of legacyConfigs) { + contexts.push({ + context: legacyTokenHandler.initialize({ + legacy: true, + options: legacyConfig, + }), + handler: legacyTokenHandler, + allAccessRestrictions: readAccessRestrictionsFromConfig(legacyConfig), + }); + } + return new ExternalAuthTokenHandler(ownPluginId, contexts); } diff --git a/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts index a278e5766c..48dbcc2418 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/legacy.test.ts @@ -23,6 +23,7 @@ import { legacyTokenHandler } from './legacy'; describe('LegacyTokenHandler', () => { const key1 = randomBytes(24); const key2 = randomBytes(24); + const key3 = randomBytes(24); const tokenHandler = legacyTokenHandler; @@ -40,6 +41,13 @@ describe('LegacyTokenHandler', () => { }), }); + const contextWithLegacy = tokenHandler.initialize({ + options: new ConfigReader({ + secret: key3.toString('base64'), + }), + legacy: true, + }); + it('should verify valid tokens', async () => { const token1 = await new SignJWT({ sub: 'backstage-server', @@ -62,6 +70,19 @@ describe('LegacyTokenHandler', () => { await expect(tokenHandler.verifyToken(token2, context2)).resolves.toEqual({ subject: 'key2', }); + + const token3 = await new SignJWT({ + sub: 'backstage-server', + exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(), + }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(key3); + + await expect( + tokenHandler.verifyToken(token3, contextWithLegacy), + ).resolves.toEqual({ + subject: 'external:backstage-plugin', + }); }); it('should return undefined if the token is not a valid legacy token', async () => { @@ -235,5 +256,54 @@ describe('LegacyTokenHandler', () => { ).toThrowErrorMatchingInlineSnapshot( `"Invalid type in config for key 'subject' in 'mock-config', got number, wanted string"`, ); + + // old style add + expect(() => + handler.initialize({ + options: new ConfigReader({ secret: 'b2s=' }), + legacy: true, + }), + ).not.toThrow(); + + expect(() => + handler.initialize({ + options: new ConfigReader({ _missingsecret: true }), + legacy: true, + }), + ).toThrowErrorMatchingInlineSnapshot( + `"Missing required config value at 'secret' in 'mock-config'"`, + ); + expect(() => + handler.initialize({ + options: new ConfigReader({ secret: '' }), + legacy: true, + }), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid type in config for key 'secret' in 'mock-config', got empty-string, wanted string"`, + ); + expect(() => + handler.initialize({ + options: new ConfigReader({ secret: 'has spaces' }), + legacy: true, + }), + ).toThrowErrorMatchingInlineSnapshot( + `"Illegal secret, must be a valid base64 string"`, + ); + expect(() => + handler.initialize({ + options: new ConfigReader({ secret: 'hasnewline\n' }), + legacy: true, + }), + ).toThrowErrorMatchingInlineSnapshot( + `"Illegal secret, must be a valid base64 string"`, + ); + expect(() => + handler.initialize({ + options: new ConfigReader({ secret: 3 }), + legacy: true, + }), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid type in config for key 'secret' in 'mock-config', got number, wanted string"`, + ); }); }); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts b/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts index f9932a8735..2ab8c3a5ea 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/legacy.ts @@ -17,7 +17,7 @@ import { Config } from '@backstage/config'; import { base64url, decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose'; -import { createExternalTokenHandler } from './helpers'; +import { ExternalTokenHandler } from './types'; export type LegacyConfigWrapper = { legacy: boolean; @@ -30,64 +30,74 @@ type LegacyTokenHandlerContext = { subject: string; }; -export const legacyTokenHandler = - createExternalTokenHandler({ - type: 'legacy', - initialize(ctx: { options: Config }): LegacyTokenHandlerContext { - const secret = ctx.options.getString('secret'); - const subject = ctx.options.getString('subject'); +type LegacyTokenHandlerOverloaded = + ExternalTokenHandler & { + initialize(ctx: { + options: Config; + legacy: true; + }): LegacyTokenHandlerContext; + }; - if (!secret.match(/^\S+$/)) { - throw new Error('Illegal secret, must be a valid base64 string'); - } else if (!subject.match(/^\S+$/)) { - throw new Error( - 'Illegal subject, must be a set of non-space characters', - ); - } +export const legacyTokenHandler: LegacyTokenHandlerOverloaded = { + type: 'legacy', + initialize(ctx: { + options: Config; + legacy?: true; + }): LegacyTokenHandlerContext { + const secret = ctx.options.getString('secret'); + const subject = ctx.legacy + ? 'external:backstage-plugin' + : ctx.options.getString('subject'); - try { - return { - key: base64url.decode(secret), - subject, - }; - } catch { - throw new Error('Illegal secret, must be a valid base64 string'); - } - }, + if (!secret.match(/^\S+$/)) { + throw new Error('Illegal secret, must be a valid base64 string'); + } else if (!subject.match(/^\S+$/)) { + throw new Error('Illegal subject, must be a set of non-space characters'); + } - async verifyToken(token: string, context: LegacyTokenHandlerContext) { - // First do a duck typing check to see if it remotely looks like a legacy token - try { - // We do a fair amount of checking upfront here. Since we aren't certain - // that it's even the right type of key that we're looking at, we can't - // defer eg the alg check to jwtVerify, because it won't be possible to - // discern different reasons for key verification failures from each other - // easily - const { alg } = decodeProtectedHeader(token); - if (alg !== 'HS256') { - return undefined; - } - const { sub, aud } = decodeJwt(token); - if (sub !== 'backstage-server' || aud) { - return undefined; - } - } catch (e) { - // Doesn't look like a jwt at all + try { + return { + key: base64url.decode(secret), + subject, + }; + } catch { + throw new Error('Illegal secret, must be a valid base64 string'); + } + }, + + async verifyToken(token: string, context: LegacyTokenHandlerContext) { + // First do a duck typing check to see if it remotely looks like a legacy token + try { + // We do a fair amount of checking upfront here. Since we aren't certain + // that it's even the right type of key that we're looking at, we can't + // defer eg the alg check to jwtVerify, because it won't be possible to + // discern different reasons for key verification failures from each other + // easily + const { alg } = decodeProtectedHeader(token); + if (alg !== 'HS256') { return undefined; } - - try { - await jwtVerify(token, context.key); - return { - subject: context.subject, - }; - } catch (error) { - if (error.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') { - throw error; - } + const { sub, aud } = decodeJwt(token); + if (sub !== 'backstage-server' || aud) { + return undefined; } - - // None of the signing keys matched + } catch (e) { + // Doesn't look like a jwt at all return undefined; - }, - }); + } + + try { + await jwtVerify(token, context.key); + return { + subject: context.subject, + }; + } catch (error) { + if (error.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') { + throw error; + } + } + + // None of the signing keys matched + return undefined; + }, +}; diff --git a/packages/backend-defaults/src/entrypoints/auth/external/types.ts b/packages/backend-defaults/src/entrypoints/auth/external/types.ts index 6ce1ca13b6..01d5a26149 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/types.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/types.ts @@ -17,9 +17,6 @@ import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api'; import type { Config } from '@backstage/config'; -/** - * @public - */ export type AccessRestrictionsMap = Map< string, // plugin ID BackstagePrincipalAccessRestrictions @@ -38,16 +35,3 @@ export interface ExternalTokenHandler { ctx: TContext, ): Promise<{ subject: string } | undefined>; } - -// /** -// * @public -// * This interface is used to handle external tokens. -// */ -// export interface TokenTypeHandler { -// type: string; -// /** -// * A factory function that takes all token configuration for a given type -// * and returns a TokenHandler or an array of TokenHandlers. -// */ -// factory: (configs: Config[]) => TokenHandler | TokenHandler[]; -// } diff --git a/packages/backend-defaults/src/entrypoints/auth/index.ts b/packages/backend-defaults/src/entrypoints/auth/index.ts index e205c8b566..69d41b0310 100644 --- a/packages/backend-defaults/src/entrypoints/auth/index.ts +++ b/packages/backend-defaults/src/entrypoints/auth/index.ts @@ -21,9 +21,8 @@ export { export { createExternalTokenHandler } from './external/helpers'; -export { externalTokenTypeHandlersRef } from './external/ExternalAuthTokenHandler'; +export { externalTokenHandlersServiceRef } from './external/ExternalAuthTokenHandler'; export type { ExternalTokenHandler } from './external/types'; -export type { AccessRestrictionsMap } from './external/types'; export type { PluginTokenHandler } from './plugin/PluginTokenHandler'; From fe17ef8115b79c300ac78b253cd78b553836fc8e Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Mon, 15 Sep 2025 23:08:47 +0200 Subject: [PATCH 14/15] failing api report Signed-off-by: Juan Pablo Garcia Ripa --- plugins/catalog-react/report-alpha.api.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 30cdd91704..cf28d80c11 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -92,12 +92,12 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'entityTableColumnTitle.title': 'Title'; readonly 'entityTableColumnTitle.description': 'Description'; readonly 'entityTableColumnTitle.domain': 'Domain'; + readonly 'entityTableColumnTitle.system': 'System'; + readonly 'entityTableColumnTitle.tags': 'Tags'; readonly 'entityTableColumnTitle.namespace': 'Namespace'; readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; readonly 'entityTableColumnTitle.owner': 'Owner'; - readonly 'entityTableColumnTitle.system': 'System'; readonly 'entityTableColumnTitle.targets': 'Targets'; - readonly 'entityTableColumnTitle.tags': 'Tags'; } >; @@ -533,8 +533,8 @@ export const EntityTableColumnTitle: ({ translationKey, }: EntityTableColumnTitleProps) => | 'Title' - | 'Domain' | 'System' + | 'Domain' | 'Lifecycle' | 'Namespace' | 'Owner' From c8cad6ac273cd1ea416330cfc137a2c2c87d7f8f Mon Sep 17 00:00:00 2001 From: Juan Pablo Garcia Ripa Date: Wed, 24 Sep 2025 09:46:02 +0200 Subject: [PATCH 15/15] prevent handlers overrides Signed-off-by: Juan Pablo Garcia Ripa --- .../external/ExternalAuthTokenHandler.test.ts | 70 +++++++++++++++++++ .../auth/external/ExternalAuthTokenHandler.ts | 33 ++++++--- .../src/entrypoints/auth/external/jwks.ts | 1 - 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts index f315b43314..e3f1acf270 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.test.ts @@ -354,6 +354,76 @@ describe('ExternalTokenHandler', () => { expect(verifyMock).toHaveBeenCalledWith(customToken, { context: 'a' }); }); + it('should fail if custom handler has same type as builtin handlers', async () => { + const logger = mockServices.logger.mock(); + const customStaticHandler: ExternalTokenHandler = + createExternalTokenHandler({ + type: 'static', + initialize: jest.fn().mockResolvedValue(undefined), + verifyToken: jest.fn().mockResolvedValue({ + subject: 'custom-static-subject', + }), + }); + + const createHandler = () => + ExternalAuthTokenHandler.create({ + ownPluginId: 'catalog', + logger, + config: mockServices.rootConfig({ + data: { + backend: { + auth: { + externalAccess: [ + { + type: 'static', + options: { + token: 'mytoken', + subject: 'static-subject', + }, + accessRestrictions: [ + { plugin: 'catalog', permission: 'catalog.entity.read' }, + ], + }, + ], + }, + }, + }, + }), + externalTokenHandlers: [customStaticHandler], + }); + + expect(createHandler).toThrowErrorMatchingInlineSnapshot( + `"Duplicate external token handler type 'static', each handler must have a unique type"`, + ); + }); + it('should fail if 2 custom handlers have the same type', async () => { + const createHandler = () => + ExternalAuthTokenHandler.create({ + ownPluginId: 'catalog', + logger: mockServices.logger.mock(), + config: mockServices.rootConfig(), + externalTokenHandlers: [ + createExternalTokenHandler({ + type: 'internal-custom', + initialize: jest.fn().mockResolvedValue(undefined), + verifyToken: jest.fn().mockResolvedValue({ + subject: 'sub', + }), + }), + createExternalTokenHandler({ + type: 'internal-custom', + initialize: jest.fn().mockResolvedValue(undefined), + verifyToken: jest.fn().mockResolvedValue({ + subject: 'sub', + }), + }), + ], + }); + + expect(createHandler).toThrowErrorMatchingInlineSnapshot( + `"Duplicate external token handler type 'internal-custom', each handler must have a unique type"`, + ); + }); it('should fail if config contains types not declared', async () => { const createHandler = () => ExternalAuthTokenHandler.create({ diff --git a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts index 4b2cbb3e5a..21b3aed99d 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/ExternalAuthTokenHandler.ts @@ -42,11 +42,11 @@ export const externalTokenHandlersServiceRef = createServiceRef< multiton: true, }); -const defaultHandlers: ExternalTokenHandler[] = [ - staticTokenHandler, - legacyTokenHandler, - jwksTokenHandler, -]; +const defaultHandlers: Record> = { + static: staticTokenHandler, + legacy: legacyTokenHandler, + jwks: jwksTokenHandler, +}; type ContextMapEntry = { context: T; @@ -70,20 +70,35 @@ export class ExternalAuthTokenHandler { const { ownPluginId, config, - externalTokenHandlers: customHandlers, + externalTokenHandlers: customHandlers = [], logger, } = options; - const handlersTypes = [...defaultHandlers, ...(customHandlers ?? [])]; + const handlersTypes = customHandlers.reduce< + Record> + >( + (acc, handler) => { + if (acc[handler.type]) { + throw new Error( + `Duplicate external token handler type '${handler.type}', each handler must have a unique type`, + ); + } + acc[handler.type] = handler; + return acc; + }, + { ...defaultHandlers }, + ); const handlerConfigs = config.getOptionalConfigArray(NEW_CONFIG_KEY) ?? []; const contexts: ContextMapEntry[] = handlerConfigs.map( handlerConfig => { const type = handlerConfig.getString('type'); - const handler = handlersTypes.find(h => h.type === type); + const handler = handlersTypes[type]; if (!handler) { - const valid = handlersTypes.map(h => `'${h.type}'`).join(', '); + const valid = Object.keys(handlersTypes) + .map(h => `'${h}'`) + .join(', '); throw new Error( `Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`, ); diff --git a/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts b/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts index 892fcf2181..33b55df775 100644 --- a/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts +++ b/packages/backend-defaults/src/entrypoints/auth/external/jwks.ts @@ -75,7 +75,6 @@ export const jwksTokenHandler = createExternalTokenHandler({ : 'external:'; return { subject: `${prefix}${sub}`, - allAccessRestrictions: context.allAccessRestrictions, }; } } catch {