feat: use multiple Config factory

Signed-off-by: Juan Pablo Garcia Ripa <sarabadu@gmail.com>
This commit is contained in:
Juan Pablo Garcia Ripa
2025-04-25 08:46:55 +02:00
parent 9377fbfb0f
commit 410b81afd7
13 changed files with 392 additions and 242 deletions
+3 -4
View File
@@ -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<
| {
@@ -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',
}),
}),
}),
}),
]),
);
});
});
@@ -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,
@@ -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'"`,
);
});
});
@@ -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<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 = [];
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<string, Config[]> & {
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(
@@ -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 } });
@@ -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',
@@ -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"`,
);
@@ -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;
}
@@ -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"`,
);
@@ -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);
@@ -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;
@@ -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';