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] 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(