feat: use multiple Config factory
Signed-off-by: Juan Pablo Garcia Ripa <sarabadu@gmail.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
+78
-22
@@ -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'"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+86
-31
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user