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] 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';