feat: add external token decorator service

Signed-off-by: Juan Pablo Garcia Ripa <sarabadu@gmail.com>
This commit is contained in:
Juan Pablo Garcia Ripa
2025-03-20 12:00:23 +01:00
parent 89f191b72c
commit 8495b18507
9 changed files with 309 additions and 11 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': minor
---
Add a new `externalTokenHandlerDecoratorServiceRef` to allow custom external token validations
+30 -1
View File
@@ -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);
},
});
```
@@ -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)
@@ -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');
});
});
});
@@ -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(
@@ -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'"`,
);
});
});
@@ -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<string, TokenHandler> = {
static: staticHandler,
legacy: legacyHandler,
jwks: jwksHandler,
const handlers: Record<string, (config: Config) => 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(
@@ -17,6 +17,9 @@
export {
authServiceFactory,
pluginTokenHandlerDecoratorServiceRef,
externalTokenHandlerDecoratorServiceRef,
} from './authServiceFactory';
export type { ExternalTokenHandler } from './external/ExternalTokenHandler';
export type { PluginTokenHandler } from './plugin/PluginTokenHandler';
@@ -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(