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