bring the legacy config back

Signed-off-by: Juan Pablo Garcia Ripa <sarabadu@gmail.com>
This commit is contained in:
Juan Pablo Garcia Ripa
2025-09-15 22:40:23 +02:00
parent 823ed49a43
commit 818c835a10
11 changed files with 219 additions and 143 deletions
-21
View File
@@ -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
```
+7 -29
View File
@@ -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({
+1 -8
View File
@@ -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<TContext> {
}
// @public
export const externalTokenTypeHandlersRef: ServiceRef<
export const externalTokenHandlersServiceRef: ServiceRef<
ExternalTokenHandler<unknown>,
'plugin',
'multiton'
@@ -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;
@@ -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,
@@ -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',
@@ -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<unknown>
>({
id: 'core.auth.externalTokenHandlers',
multiton: true,
// defaultFactory // :pepe-think: seems like is not possible to use defaultFactory with multiton
});
const defaultHandlers: ExternalTokenHandler<unknown>[] = [
@@ -54,6 +53,7 @@ type ContextMapEntry<T> = {
handler: ExternalTokenHandler<T>;
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);
}
@@ -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"`,
);
});
});
@@ -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<LegacyTokenHandlerContext>({
type: 'legacy',
initialize(ctx: { options: Config }): LegacyTokenHandlerContext {
const secret = ctx.options.getString('secret');
const subject = ctx.options.getString('subject');
type LegacyTokenHandlerOverloaded =
ExternalTokenHandler<LegacyTokenHandlerContext> & {
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;
},
};
@@ -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<TContext> {
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[];
// }
@@ -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';