feat: add external token decorator service
Signed-off-by: Juan Pablo Garcia Ripa <sarabadu@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-defaults': minor
|
||||
---
|
||||
|
||||
Add a new `externalTokenHandlerDecoratorServiceRef` to allow custom external token validations
|
||||
@@ -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(
|
||||
|
||||
+144
@@ -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'"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+19
-7
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user