Merge pull request #28942 from Sarabadu/externalTokenDecorator
RFC add external token decorator service
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-defaults': minor
|
||||
---
|
||||
|
||||
Add a new `externalTokenHandlersServiceRef` 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 `externalTokenHandlersServiceRef` can be used to extend the existing token handler without having to re-implement the entire `AuthService` implementation.
|
||||
This is particularly useful when you want to add additional logic to the handler, such as logging or metrics or custom token validation.
|
||||
|
||||
### PluginTokenHandler decoration
|
||||
|
||||
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,109 @@ const decoratedPluginTokenHandler = createServiceFactory({
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Adding custom ExternalTokenHandler
|
||||
|
||||
The `externalTokenHandlersServiceRef` can be used to add custom external token handlers to the default implementation.
|
||||
|
||||
Your service factory must return an object with a `type` property that matches the token type in your configuration (e.g., 'custom', 'api-key'). When Backstage encounters tokens of this type, it calls your `initialize` method with all the configuration entries that match this type. Your factory can return either a single token handler or an array of handlers to process and validate these tokens.
|
||||
|
||||
:::note Note
|
||||
|
||||
During token verification, all the token handlers are tested. Consider this when adding many token handlers, as it may impact performance.
|
||||
|
||||
:::
|
||||
|
||||
For example, if we want to add a custom external token handler for the `custom` type:
|
||||
|
||||
our config would look like this:
|
||||
|
||||
```yaml title="in e.g. app-config.production.yaml"
|
||||
backend:
|
||||
auth:
|
||||
externalAccess:
|
||||
- type: custom
|
||||
options:
|
||||
customOptions: additional-value
|
||||
accessRestrictions:
|
||||
- plugin: events
|
||||
- type: custom
|
||||
options:
|
||||
customOptions: another-value
|
||||
accessRestrictions:
|
||||
- plugin: events
|
||||
```
|
||||
|
||||
And we can implement the custom token handler like this:
|
||||
|
||||
```ts
|
||||
import {
|
||||
ExternalTokenHandler,
|
||||
externalTokenHandlersServiceRef,
|
||||
createExternalTokenHandler,
|
||||
} from '@backstage/backend-defaults/auth';
|
||||
import { createServiceFactory } from '@backstage/backend-plugin-api';
|
||||
|
||||
const customExternalTokenHandlers = createServiceFactory({
|
||||
service: externalTokenHandlersServiceRef,
|
||||
deps: {},
|
||||
async factory() {
|
||||
return createExternalTokenHandler({
|
||||
type: 'custom',
|
||||
initialize({ options }) {
|
||||
// Initialize your handler context from config
|
||||
const customOptions = options.getString('customOptions');
|
||||
return { customOptions };
|
||||
},
|
||||
async verifyToken(token, context) {
|
||||
// Your custom token validation logic here
|
||||
// Return undefined if token is invalid
|
||||
// Return { subject: 'your-subject' } if token is valid
|
||||
|
||||
if (token === 'valid-token') {
|
||||
return { subject: `custom:${context.customOptions}` };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The `createExternalTokenHandler` helper simplifies creating external token handlers with the new API:
|
||||
|
||||
- **`type`**: A string identifier for your token handler type that matches the config
|
||||
- **`initialize`**: Called once for each config entry of this type, receives the config options and returns a context object that will be passed to `verifyToken`
|
||||
- **`verifyToken`**: Called for each token verification with the token and context, returns the subject if valid or `undefined` if not
|
||||
|
||||
```ts
|
||||
// Example of a more complex handler with external API call
|
||||
const apiTokenHandler = createExternalTokenHandler({
|
||||
type: 'api-validation',
|
||||
initialize({ options }) {
|
||||
const apiBaseUrl = options.getString('apiBaseUrl');
|
||||
const apiKey = options.getString('apiKey');
|
||||
return { apiBaseUrl, apiKey };
|
||||
},
|
||||
async verifyToken(token, { apiBaseUrl, apiKey }) {
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/validate-token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const { userId } = await response.json();
|
||||
return { subject: `api:${userId}` };
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't throw - return undefined for invalid tokens
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -223,6 +223,35 @@ export const customFooServiceFactory = createServiceFactory({
|
||||
|
||||
This allows you to provide more advanced options for the service implementation that couldn't be expressed through static configuration. It also gives users of the service implementation access to other services through dependency injection, which can be useful for their customizations.
|
||||
|
||||
## Multiton
|
||||
|
||||
By default the service reference will point to a singleton instance of the service. This mean if a new service factory uses this reference it will override the previous one. This is the most common use-case, but in some cases you may want to have multiple instances of the same service.
|
||||
For some services, it is desirable to extend the functionality instead of overriding it. For example, some services could have many handlers to address specific events, and you may want to add a new handler instead of overriding the previous one. In this case, you can use the `multiton` option when creating the service reference:
|
||||
|
||||
```ts
|
||||
// example-service-ref.ts
|
||||
import { createServiceRef } from '@backstage/backend-plugin-api';
|
||||
|
||||
export interface FooService {
|
||||
foo(options: FooOptions): Promise<FooResult>;
|
||||
}
|
||||
|
||||
export const fooServiceRef = createServiceRef<FooService>({
|
||||
id: 'example.foo',
|
||||
multiton: true, // this service ref will be an array of instances
|
||||
});
|
||||
```
|
||||
|
||||
When adding this `serviceRef` as a dependency to a factory, the factory will receive an array of instances instead of a single instance:
|
||||
|
||||
```ts
|
||||
deps: {fooServices: fooServiceRef},
|
||||
factory(fooServices) {
|
||||
// fooServices is an array of instances
|
||||
return new Bar(fooServices);
|
||||
},
|
||||
```
|
||||
|
||||
## Service Factory Options Pattern
|
||||
|
||||
:::note Note
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
```ts
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import type { Config } from '@backstage/config';
|
||||
import { ServiceFactory } from '@backstage/backend-plugin-api';
|
||||
import { ServiceRef } from '@backstage/backend-plugin-api';
|
||||
|
||||
@@ -14,6 +15,36 @@ export const authServiceFactory: ServiceFactory<
|
||||
'singleton'
|
||||
>;
|
||||
|
||||
// @public
|
||||
export function createExternalTokenHandler<TContext>(
|
||||
handler: ExternalTokenHandler<TContext>,
|
||||
): ExternalTokenHandler<TContext>;
|
||||
|
||||
// @public
|
||||
export interface ExternalTokenHandler<TContext> {
|
||||
// (undocumented)
|
||||
initialize(ctx: { options: Config }): TContext;
|
||||
// (undocumented)
|
||||
type: string;
|
||||
// (undocumented)
|
||||
verifyToken(
|
||||
token: string,
|
||||
ctx: TContext,
|
||||
): Promise<
|
||||
| {
|
||||
subject: string;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const externalTokenHandlersServiceRef: ServiceRef<
|
||||
ExternalTokenHandler<unknown>,
|
||||
'plugin',
|
||||
'multiton'
|
||||
>;
|
||||
|
||||
// @public
|
||||
export interface PluginTokenHandler {
|
||||
// (undocumented)
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { decodeJwt } from 'jose';
|
||||
import { ExternalTokenHandler } from './external/ExternalTokenHandler';
|
||||
import { ExternalAuthTokenHandler } from './external/ExternalAuthTokenHandler';
|
||||
import {
|
||||
createCredentialsWithNonePrincipal,
|
||||
createCredentialsWithServicePrincipal,
|
||||
@@ -41,7 +41,7 @@ export class DefaultAuthService implements AuthService {
|
||||
constructor(
|
||||
private readonly userTokenHandler: UserTokenHandler,
|
||||
private readonly pluginTokenHandler: PluginTokenHandler,
|
||||
private readonly externalTokenHandler: ExternalTokenHandler,
|
||||
private readonly externalTokenHandler: ExternalAuthTokenHandler,
|
||||
private readonly pluginId: string,
|
||||
private readonly disableDefaultAuthPolicy: boolean,
|
||||
private readonly pluginKeySource: PluginKeySource,
|
||||
|
||||
@@ -31,6 +31,9 @@ import { toInternalBackstageCredentials } from './helpers';
|
||||
import { PluginTokenHandler } from './plugin/PluginTokenHandler';
|
||||
import { createServiceFactory } from '@backstage/backend-plugin-api';
|
||||
|
||||
import { externalTokenHandlersServiceRef } from './external/ExternalAuthTokenHandler';
|
||||
import { createExternalTokenHandler } from './external/helpers';
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
// TODO: Ship discovery mock service in the service factory tester
|
||||
@@ -450,8 +453,88 @@ 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 customConfig = jest.fn();
|
||||
const deps = [
|
||||
discoveryServiceFactory,
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
backend: {
|
||||
baseUrl: 'http://localhost',
|
||||
auth: {
|
||||
keys: [{ secret: 'abc' }],
|
||||
externalAccess: [
|
||||
{
|
||||
type: 'static',
|
||||
options: {
|
||||
token: 'limited-static-token',
|
||||
subject: 'limited-static-subject',
|
||||
},
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'do.it' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'static',
|
||||
options: {
|
||||
token: 'unlimited-static-token',
|
||||
subject: 'unlimited-static-subject',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'custom',
|
||||
options: {
|
||||
[`custom-config`]: 'custom-config',
|
||||
foo: 'bar',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const customExternalTokenHandler = createExternalTokenHandler<{
|
||||
[`custom-config`]: string;
|
||||
foo: string;
|
||||
}>({
|
||||
type: 'custom',
|
||||
initialize({ options }) {
|
||||
const customConfigValue = options.getString('custom-config');
|
||||
const foo = options.getString('foo');
|
||||
customConfig(customConfigValue);
|
||||
return { [`custom-config`]: customConfigValue, foo };
|
||||
},
|
||||
async verifyToken(token, ctx) {
|
||||
customLogic(token);
|
||||
return {
|
||||
subject: ctx.foo,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const customPluginTokenHandler = createServiceFactory({
|
||||
service: externalTokenHandlersServiceRef,
|
||||
deps: {},
|
||||
async factory() {
|
||||
return customExternalTokenHandler;
|
||||
},
|
||||
});
|
||||
const tester = ServiceFactoryTester.from(authServiceFactory, {
|
||||
dependencies: [...deps, customPluginTokenHandler],
|
||||
});
|
||||
const searchAuth = await tester.getSubject('search');
|
||||
await searchAuth.authenticate('custom-token');
|
||||
|
||||
expect(customLogic).toHaveBeenCalledWith('custom-token');
|
||||
expect(customConfig).toHaveBeenCalledWith('custom-config');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
createServiceRef,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { DefaultAuthService } from './DefaultAuthService';
|
||||
import { ExternalTokenHandler } from './external/ExternalTokenHandler';
|
||||
import {
|
||||
ExternalAuthTokenHandler,
|
||||
externalTokenHandlersServiceRef,
|
||||
} from './external/ExternalAuthTokenHandler';
|
||||
import {
|
||||
DefaultPluginTokenHandler,
|
||||
PluginTokenHandler,
|
||||
@@ -64,6 +67,7 @@ export const authServiceFactory = createServiceFactory({
|
||||
plugin: coreServices.pluginMetadata,
|
||||
database: coreServices.database,
|
||||
pluginTokenHandlerDecorator: pluginTokenHandlerDecoratorServiceRef,
|
||||
externalTokenHandlers: externalTokenHandlersServiceRef,
|
||||
},
|
||||
async factory({
|
||||
config,
|
||||
@@ -72,6 +76,7 @@ export const authServiceFactory = createServiceFactory({
|
||||
logger,
|
||||
database,
|
||||
pluginTokenHandlerDecorator,
|
||||
externalTokenHandlers,
|
||||
}) {
|
||||
const disableDefaultAuthPolicy =
|
||||
config.getOptionalBoolean(
|
||||
@@ -102,10 +107,11 @@ export const authServiceFactory = createServiceFactory({
|
||||
}),
|
||||
);
|
||||
|
||||
const externalTokens = ExternalTokenHandler.create({
|
||||
const externalTokens = ExternalAuthTokenHandler.create({
|
||||
ownPluginId: plugin.getId(),
|
||||
config,
|
||||
logger,
|
||||
externalTokenHandlers,
|
||||
});
|
||||
|
||||
return new DefaultAuthService(
|
||||
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api';
|
||||
import { ExternalAuthTokenHandler } from './ExternalAuthTokenHandler';
|
||||
import { createExternalTokenHandler } from './helpers';
|
||||
import { AccessRestrictionsMap, ExternalTokenHandler } from './types';
|
||||
import {
|
||||
mockServices,
|
||||
registerMswTestHooks,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||
import { DateTime } from 'luxon';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
// Simplified copy of TokenFactory in @backstage/plugin-auth-backend
|
||||
interface AnyJWK extends Record<string, string> {
|
||||
use: 'sig';
|
||||
alg: string;
|
||||
kid: string;
|
||||
kty: string;
|
||||
}
|
||||
class FakeTokenFactory {
|
||||
private readonly keys = new Array<AnyJWK>();
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
issuer: string;
|
||||
keyDurationSeconds: number;
|
||||
},
|
||||
) {}
|
||||
|
||||
async issueToken(params: {
|
||||
claims: {
|
||||
sub: string;
|
||||
ent?: string[];
|
||||
};
|
||||
}): Promise<string> {
|
||||
const pair = await generateKeyPair('RS256');
|
||||
const publicKey = await exportJWK(pair.publicKey);
|
||||
const kid = uuid();
|
||||
publicKey.kid = kid;
|
||||
this.keys.push(publicKey as AnyJWK);
|
||||
|
||||
const iss = this.options.issuer;
|
||||
const sub = params.claims.sub;
|
||||
const ent = params.claims.ent;
|
||||
const aud = 'backstage';
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + this.options.keyDurationSeconds;
|
||||
|
||||
return new SignJWT({ iss, sub, aud, iat, exp, ent, kid })
|
||||
.setProtectedHeader({ alg: 'RS256', ent: ent, kid: kid })
|
||||
.setIssuer(iss)
|
||||
.setAudience(aud)
|
||||
.setSubject(sub)
|
||||
.setIssuedAt(iat)
|
||||
.setExpirationTime(exp)
|
||||
.sign(pair.privateKey);
|
||||
}
|
||||
|
||||
async listPublicKeys(): Promise<{ keys: AnyJWK[] }> {
|
||||
return { keys: this.keys };
|
||||
}
|
||||
}
|
||||
|
||||
describe('ExternalTokenHandler', () => {
|
||||
const server = setupServer();
|
||||
registerMswTestHooks(server);
|
||||
|
||||
it('skips over inner handlers that do not match, and applies plugin restrictions', async () => {
|
||||
const handler1: ExternalTokenHandler<unknown> = createExternalTokenHandler({
|
||||
type: 'type1',
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
verifyToken: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const handler2: ExternalTokenHandler<undefined> =
|
||||
createExternalTokenHandler({
|
||||
type: 'type2',
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
verifyToken: jest.fn().mockResolvedValue({
|
||||
subject: 'sub',
|
||||
}),
|
||||
});
|
||||
|
||||
const accessRestrictions: AccessRestrictionsMap = new Map(
|
||||
Object.entries({
|
||||
plugin1: {
|
||||
permissionNames: ['do.it'],
|
||||
} satisfies BackstagePrincipalAccessRestrictions,
|
||||
}),
|
||||
);
|
||||
|
||||
const plugin1 = new ExternalAuthTokenHandler('plugin1', [
|
||||
{
|
||||
context: undefined,
|
||||
handler: handler1,
|
||||
},
|
||||
{
|
||||
context: undefined,
|
||||
handler: handler2,
|
||||
allAccessRestrictions: accessRestrictions,
|
||||
},
|
||||
]);
|
||||
const plugin2 = new ExternalAuthTokenHandler('plugin2', [
|
||||
{
|
||||
context: undefined,
|
||||
handler: handler1,
|
||||
},
|
||||
{
|
||||
context: undefined,
|
||||
handler: handler2,
|
||||
allAccessRestrictions: accessRestrictions,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(plugin1.verifyToken('token')).resolves.toEqual({
|
||||
subject: 'sub',
|
||||
accessRestrictions: { permissionNames: ['do.it'] },
|
||||
});
|
||||
await expect(
|
||||
plugin2.verifyToken('token'),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"This token's access is restricted to plugin(s) 'plugin1'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('successfully parses known methods', async () => {
|
||||
const legacyKey = randomBytes(24);
|
||||
|
||||
const factory = new FakeTokenFactory({
|
||||
issuer: 'blah',
|
||||
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 handler = ExternalAuthTokenHandler.create({
|
||||
ownPluginId: 'catalog',
|
||||
logger: mockServices.logger.mock(),
|
||||
config: mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
auth: {
|
||||
externalAccess: [
|
||||
{
|
||||
type: 'legacy',
|
||||
options: {
|
||||
secret: legacyKey.toString('base64'),
|
||||
subject: 'legacy-subject',
|
||||
},
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'static',
|
||||
options: {
|
||||
token: 'defdefdef',
|
||||
subject: 'static-subject',
|
||||
},
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'jwks',
|
||||
options: {
|
||||
url: 'https://example.com/.well-known/jwks.json',
|
||||
algorithm: 'RS256',
|
||||
issuer: 'blah',
|
||||
audience: 'backstage',
|
||||
subjectPrefix: 'custom-prefix',
|
||||
},
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const legacyToken = await new SignJWT({
|
||||
sub: 'backstage-server',
|
||||
exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(),
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(legacyKey);
|
||||
|
||||
await expect(handler.verifyToken(legacyToken)).resolves.toEqual({
|
||||
subject: 'legacy-subject',
|
||||
accessRestrictions: { permissionNames: ['catalog.entity.read'] },
|
||||
});
|
||||
|
||||
await expect(handler.verifyToken('defdefdef')).resolves.toEqual({
|
||||
subject: 'static-subject',
|
||||
accessRestrictions: { permissionNames: ['catalog.entity.read'] },
|
||||
});
|
||||
|
||||
const jwksToken = await factory.issueToken({
|
||||
claims: { sub: 'jwks-subject' },
|
||||
});
|
||||
await expect(handler.verifyToken(jwksToken)).resolves.toEqual({
|
||||
subject: 'external:custom-prefix:jwks-subject',
|
||||
accessRestrictions: { permissionNames: ['catalog.entity.read'] },
|
||||
});
|
||||
});
|
||||
it('successfully uses legacy configs', async () => {
|
||||
const legacyKey = randomBytes(24);
|
||||
const factory = new FakeTokenFactory({
|
||||
issuer: 'my-company',
|
||||
keyDurationSeconds: 100,
|
||||
});
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
'https://example.com/.well-known/jwks.json',
|
||||
async (_, res, ctx) => {
|
||||
const keys = await factory.listPublicKeys();
|
||||
return res(ctx.json(keys));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const logger = mockServices.logger.mock();
|
||||
const handler = ExternalAuthTokenHandler.create({
|
||||
ownPluginId: 'catalog',
|
||||
logger,
|
||||
config: mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
auth: {
|
||||
keys: [
|
||||
{
|
||||
secret: legacyKey.toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
`DEPRECATION WARNING: The backend.auth.keys config has been replaced by backend.auth.externalAccess, see https://backstage.io/docs/auth/service-to-service-auth`,
|
||||
);
|
||||
|
||||
const legacyToken = await new SignJWT({
|
||||
sub: 'backstage-server',
|
||||
exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(),
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(legacyKey);
|
||||
|
||||
await expect(handler.verifyToken(legacyToken)).resolves.toEqual({
|
||||
subject: 'external:backstage-plugin',
|
||||
});
|
||||
});
|
||||
it('successfully uses custom token handlers', async () => {
|
||||
const factory = new FakeTokenFactory({
|
||||
issuer: 'my-company',
|
||||
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 verifyMock = jest.fn().mockResolvedValue({});
|
||||
const initializeMock = jest.fn().mockReturnValue({ context: 'a' });
|
||||
|
||||
const handler = ExternalAuthTokenHandler.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: [
|
||||
createExternalTokenHandler({
|
||||
type: 'internal-custom',
|
||||
initialize: initializeMock,
|
||||
verifyToken: verifyMock,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(initializeMock).toHaveBeenCalledWith({
|
||||
options: expect.objectContaining({
|
||||
data: {
|
||||
issuer: 'my-company',
|
||||
subject: 'internal-subject',
|
||||
audience: 'backstage',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const customToken = await factory.issueToken({
|
||||
claims: { sub: 'internal-subject' },
|
||||
});
|
||||
|
||||
await handler.verifyToken(customToken);
|
||||
|
||||
expect(verifyMock).toHaveBeenCalledWith(customToken, { context: 'a' });
|
||||
});
|
||||
it('should fail if custom handler has same type as builtin handlers', async () => {
|
||||
const logger = mockServices.logger.mock();
|
||||
const customStaticHandler: ExternalTokenHandler<unknown> =
|
||||
createExternalTokenHandler({
|
||||
type: 'static',
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
verifyToken: jest.fn().mockResolvedValue({
|
||||
subject: 'custom-static-subject',
|
||||
}),
|
||||
});
|
||||
|
||||
const createHandler = () =>
|
||||
ExternalAuthTokenHandler.create({
|
||||
ownPluginId: 'catalog',
|
||||
logger,
|
||||
config: mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
auth: {
|
||||
externalAccess: [
|
||||
{
|
||||
type: 'static',
|
||||
options: {
|
||||
token: 'mytoken',
|
||||
subject: 'static-subject',
|
||||
},
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
externalTokenHandlers: [customStaticHandler],
|
||||
});
|
||||
|
||||
expect(createHandler).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Duplicate external token handler type 'static', each handler must have a unique type"`,
|
||||
);
|
||||
});
|
||||
it('should fail if 2 custom handlers have the same type', async () => {
|
||||
const createHandler = () =>
|
||||
ExternalAuthTokenHandler.create({
|
||||
ownPluginId: 'catalog',
|
||||
logger: mockServices.logger.mock(),
|
||||
config: mockServices.rootConfig(),
|
||||
externalTokenHandlers: [
|
||||
createExternalTokenHandler({
|
||||
type: 'internal-custom',
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
verifyToken: jest.fn().mockResolvedValue({
|
||||
subject: 'sub',
|
||||
}),
|
||||
}),
|
||||
createExternalTokenHandler({
|
||||
type: 'internal-custom',
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
verifyToken: jest.fn().mockResolvedValue({
|
||||
subject: 'sub',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(createHandler).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Duplicate external token handler type 'internal-custom', each handler must have a unique type"`,
|
||||
);
|
||||
});
|
||||
it('should fail if config contains types not declared', async () => {
|
||||
const createHandler = () =>
|
||||
ExternalAuthTokenHandler.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 = () =>
|
||||
ExternalAuthTokenHandler.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: [
|
||||
createExternalTokenHandler({
|
||||
type: 'internal-custom',
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
verifyToken: jest.fn().mockResolvedValue({
|
||||
subject: 'sub',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(createHandler).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Unknown type 'internal-custom-invalid' in backend.auth.externalAccess, expected one of 'static', 'legacy', 'jwks', 'internal-custom'"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
BackstagePrincipalAccessRestrictions,
|
||||
createServiceRef,
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { NotAllowedError } from '@backstage/errors';
|
||||
import { legacyTokenHandler } from './legacy';
|
||||
import { staticTokenHandler } from './static';
|
||||
import { jwksTokenHandler } from './jwks';
|
||||
import { AccessRestrictionsMap, ExternalTokenHandler } from './types';
|
||||
import { readAccessRestrictionsFromConfig } from './helpers';
|
||||
|
||||
const NEW_CONFIG_KEY = 'backend.auth.externalAccess';
|
||||
const OLD_CONFIG_KEY = 'backend.auth.keys';
|
||||
let loggedDeprecationWarning = false;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* This service is used to add custom handlers for external token.
|
||||
*/
|
||||
export const externalTokenHandlersServiceRef = createServiceRef<
|
||||
ExternalTokenHandler<unknown>
|
||||
>({
|
||||
id: 'core.auth.externalTokenHandlers',
|
||||
multiton: true,
|
||||
});
|
||||
|
||||
const defaultHandlers: Record<string, ExternalTokenHandler<unknown>> = {
|
||||
static: staticTokenHandler,
|
||||
legacy: legacyTokenHandler,
|
||||
jwks: jwksTokenHandler,
|
||||
};
|
||||
|
||||
type ContextMapEntry<T> = {
|
||||
context: T;
|
||||
handler: ExternalTokenHandler<T>;
|
||||
allAccessRestrictions?: AccessRestrictionsMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles all types of external caller token types (i.e. not Backstage user
|
||||
* tokens, nor Backstage backend plugin tokens).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class ExternalAuthTokenHandler {
|
||||
static create(options: {
|
||||
ownPluginId: string;
|
||||
config: RootConfigService;
|
||||
logger: LoggerService;
|
||||
externalTokenHandlers?: ExternalTokenHandler<unknown>[];
|
||||
}): ExternalAuthTokenHandler {
|
||||
const {
|
||||
ownPluginId,
|
||||
config,
|
||||
externalTokenHandlers: customHandlers = [],
|
||||
logger,
|
||||
} = options;
|
||||
|
||||
const handlersTypes = customHandlers.reduce<
|
||||
Record<string, ExternalTokenHandler<unknown>>
|
||||
>(
|
||||
(acc, handler) => {
|
||||
if (acc[handler.type]) {
|
||||
throw new Error(
|
||||
`Duplicate external token handler type '${handler.type}', each handler must have a unique type`,
|
||||
);
|
||||
}
|
||||
acc[handler.type] = handler;
|
||||
return acc;
|
||||
},
|
||||
{ ...defaultHandlers },
|
||||
);
|
||||
|
||||
const handlerConfigs = config.getOptionalConfigArray(NEW_CONFIG_KEY) ?? [];
|
||||
const contexts: ContextMapEntry<unknown>[] = handlerConfigs.map(
|
||||
handlerConfig => {
|
||||
const type = handlerConfig.getString('type');
|
||||
|
||||
const handler = handlersTypes[type];
|
||||
if (!handler) {
|
||||
const valid = Object.keys(handlersTypes)
|
||||
.map(h => `'${h}'`)
|
||||
.join(', ');
|
||||
throw new Error(
|
||||
`Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
context: handler.initialize({
|
||||
options: handlerConfig.getConfig('options'),
|
||||
}),
|
||||
handler,
|
||||
allAccessRestrictions: handlerConfig
|
||||
? readAccessRestrictionsFromConfig(handlerConfig)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Load the old keys too
|
||||
const legacyConfigs = config.getOptionalConfigArray(OLD_CONFIG_KEY) ?? [];
|
||||
|
||||
if (legacyConfigs.length && !loggedDeprecationWarning) {
|
||||
loggedDeprecationWarning = true;
|
||||
|
||||
logger.warn(
|
||||
`DEPRECATION WARNING: The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const legacyConfig of legacyConfigs) {
|
||||
contexts.push({
|
||||
context: legacyTokenHandler.initialize({
|
||||
legacy: true,
|
||||
options: legacyConfig,
|
||||
}),
|
||||
handler: legacyTokenHandler,
|
||||
allAccessRestrictions: readAccessRestrictionsFromConfig(legacyConfig),
|
||||
});
|
||||
}
|
||||
|
||||
return new ExternalAuthTokenHandler(ownPluginId, contexts);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly ownPluginId: string,
|
||||
private readonly contexts: {
|
||||
context: unknown;
|
||||
handler: ExternalTokenHandler<unknown>;
|
||||
allAccessRestrictions?: AccessRestrictionsMap;
|
||||
}[],
|
||||
) {}
|
||||
|
||||
async verifyToken(token: string): Promise<
|
||||
| {
|
||||
subject: string;
|
||||
accessRestrictions?: BackstagePrincipalAccessRestrictions;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
for (const { handler, allAccessRestrictions, context } of this.contexts) {
|
||||
const result = await handler.verifyToken(token, context);
|
||||
if (result) {
|
||||
if (allAccessRestrictions) {
|
||||
const accessRestrictions = allAccessRestrictions.get(
|
||||
this.ownPluginId,
|
||||
);
|
||||
if (!accessRestrictions) {
|
||||
const valid = [...allAccessRestrictions.keys()]
|
||||
.map(k => `'${k}'`)
|
||||
.join(', ');
|
||||
throw new NotAllowedError(
|
||||
`This token's access is restricted to plugin(s) ${valid}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
accessRestrictions,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
-211
@@ -1,211 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api';
|
||||
import { ExternalTokenHandler } from './ExternalTokenHandler';
|
||||
import { TokenHandler } from './types';
|
||||
import {
|
||||
mockServices,
|
||||
registerMswTestHooks,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||
import { DateTime } from 'luxon';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
// Simplified copy of TokenFactory in @backstage/plugin-auth-backend
|
||||
interface AnyJWK extends Record<string, string> {
|
||||
use: 'sig';
|
||||
alg: string;
|
||||
kid: string;
|
||||
kty: string;
|
||||
}
|
||||
class FakeTokenFactory {
|
||||
private readonly keys = new Array<AnyJWK>();
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
issuer: string;
|
||||
keyDurationSeconds: number;
|
||||
},
|
||||
) {}
|
||||
|
||||
async issueToken(params: {
|
||||
claims: {
|
||||
sub: string;
|
||||
ent?: string[];
|
||||
};
|
||||
}): Promise<string> {
|
||||
const pair = await generateKeyPair('RS256');
|
||||
const publicKey = await exportJWK(pair.publicKey);
|
||||
const kid = uuid();
|
||||
publicKey.kid = kid;
|
||||
this.keys.push(publicKey as AnyJWK);
|
||||
|
||||
const iss = this.options.issuer;
|
||||
const sub = params.claims.sub;
|
||||
const ent = params.claims.ent;
|
||||
const aud = 'backstage';
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + this.options.keyDurationSeconds;
|
||||
|
||||
return new SignJWT({ iss, sub, aud, iat, exp, ent, kid })
|
||||
.setProtectedHeader({ alg: 'RS256', ent: ent, kid: kid })
|
||||
.setIssuer(iss)
|
||||
.setAudience(aud)
|
||||
.setSubject(sub)
|
||||
.setIssuedAt(iat)
|
||||
.setExpirationTime(exp)
|
||||
.sign(pair.privateKey);
|
||||
}
|
||||
|
||||
async listPublicKeys(): Promise<{ keys: AnyJWK[] }> {
|
||||
return { keys: this.keys };
|
||||
}
|
||||
}
|
||||
|
||||
describe('ExternalTokenHandler', () => {
|
||||
const server = setupServer();
|
||||
registerMswTestHooks(server);
|
||||
|
||||
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(
|
||||
Object.entries({
|
||||
plugin1: {
|
||||
permissionNames: ['do.it'],
|
||||
} satisfies BackstagePrincipalAccessRestrictions,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
const plugin1 = new ExternalTokenHandler('plugin1', [handler1, handler2]);
|
||||
const plugin2 = new ExternalTokenHandler('plugin2', [handler1, handler2]);
|
||||
|
||||
await expect(plugin1.verifyToken('token')).resolves.toEqual({
|
||||
subject: 'sub',
|
||||
accessRestrictions: { permissionNames: ['do.it'] },
|
||||
});
|
||||
await expect(
|
||||
plugin2.verifyToken('token'),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"This token's access is restricted to plugin(s) 'plugin1'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('successfully parses known methods', async () => {
|
||||
const legacyKey = randomBytes(24);
|
||||
|
||||
const factory = new FakeTokenFactory({
|
||||
issuer: 'blah',
|
||||
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 handler = ExternalTokenHandler.create({
|
||||
ownPluginId: 'catalog',
|
||||
logger: mockServices.logger.mock(),
|
||||
config: mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
auth: {
|
||||
externalAccess: [
|
||||
{
|
||||
type: 'legacy',
|
||||
options: {
|
||||
secret: legacyKey.toString('base64'),
|
||||
subject: 'legacy-subject',
|
||||
},
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'static',
|
||||
options: {
|
||||
token: 'defdefdef',
|
||||
subject: 'static-subject',
|
||||
},
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'jwks',
|
||||
options: {
|
||||
url: 'https://example.com/.well-known/jwks.json',
|
||||
algorithm: 'RS256',
|
||||
issuer: 'blah',
|
||||
audience: 'backstage',
|
||||
subjectPrefix: 'custom-prefix',
|
||||
},
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const legacyToken = await new SignJWT({
|
||||
sub: 'backstage-server',
|
||||
exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(),
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(legacyKey);
|
||||
|
||||
await expect(handler.verifyToken(legacyToken)).resolves.toEqual({
|
||||
subject: 'legacy-subject',
|
||||
accessRestrictions: { permissionNames: ['catalog.entity.read'] },
|
||||
});
|
||||
|
||||
await expect(handler.verifyToken('defdefdef')).resolves.toEqual({
|
||||
subject: 'static-subject',
|
||||
accessRestrictions: { permissionNames: ['catalog.entity.read'] },
|
||||
});
|
||||
|
||||
const jwksToken = await factory.issueToken({
|
||||
claims: { sub: 'jwks-subject' },
|
||||
});
|
||||
await expect(handler.verifyToken(jwksToken)).resolves.toEqual({
|
||||
subject: 'external:custom-prefix:jwks-subject',
|
||||
accessRestrictions: { permissionNames: ['catalog.entity.read'] },
|
||||
});
|
||||
});
|
||||
});
|
||||
-127
@@ -1,127 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
BackstagePrincipalAccessRestrictions,
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { NotAllowedError } from '@backstage/errors';
|
||||
import { LegacyTokenHandler } from './legacy';
|
||||
import { StaticTokenHandler } from './static';
|
||||
import { JWKSHandler } from './jwks';
|
||||
import { TokenHandler } from './types';
|
||||
|
||||
const NEW_CONFIG_KEY = 'backend.auth.externalAccess';
|
||||
const OLD_CONFIG_KEY = 'backend.auth.keys';
|
||||
let loggedDeprecationWarning = false;
|
||||
|
||||
/**
|
||||
* Handles all types of external caller token types (i.e. not Backstage user
|
||||
* tokens, nor Backstage backend plugin tokens).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class ExternalTokenHandler {
|
||||
static create(options: {
|
||||
ownPluginId: string;
|
||||
config: RootConfigService;
|
||||
logger: LoggerService;
|
||||
}): ExternalTokenHandler {
|
||||
const { ownPluginId, config, logger } = options;
|
||||
|
||||
const staticHandler = new StaticTokenHandler();
|
||||
const legacyHandler = new LegacyTokenHandler();
|
||||
const jwksHandler = new JWKSHandler();
|
||||
const handlers: Record<string, TokenHandler> = {
|
||||
static: staticHandler,
|
||||
legacy: legacyHandler,
|
||||
jwks: jwksHandler,
|
||||
};
|
||||
|
||||
// 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}`,
|
||||
);
|
||||
}
|
||||
handler.add(handlerConfig);
|
||||
}
|
||||
|
||||
// Load the old keys too
|
||||
const legacyConfigs = config.getOptionalConfigArray(OLD_CONFIG_KEY) ?? [];
|
||||
if (legacyConfigs.length && !loggedDeprecationWarning) {
|
||||
loggedDeprecationWarning = true;
|
||||
logger.warn(
|
||||
`DEPRECATION WARNING: The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`,
|
||||
);
|
||||
}
|
||||
for (const handlerConfig of legacyConfigs) {
|
||||
legacyHandler.addOld(handlerConfig);
|
||||
}
|
||||
|
||||
return new ExternalTokenHandler(ownPluginId, Object.values(handlers));
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly ownPluginId: string,
|
||||
private readonly handlers: TokenHandler[],
|
||||
) {}
|
||||
|
||||
async verifyToken(token: string): Promise<
|
||||
| {
|
||||
subject: string;
|
||||
accessRestrictions?: BackstagePrincipalAccessRestrictions;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
for (const handler of this.handlers) {
|
||||
const result = await handler.verifyToken(token);
|
||||
if (result) {
|
||||
const { allAccessRestrictions, ...rest } = result;
|
||||
if (allAccessRestrictions) {
|
||||
const accessRestrictions = allAccessRestrictions.get(
|
||||
this.ownPluginId,
|
||||
);
|
||||
if (!accessRestrictions) {
|
||||
const valid = [...allAccessRestrictions.keys()]
|
||||
.map(k => `'${k}'`)
|
||||
.join(', ');
|
||||
throw new NotAllowedError(
|
||||
`This token's access is restricted to plugin(s) ${valid}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
accessRestrictions,
|
||||
};
|
||||
}
|
||||
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { AccessRestriptionsMap } from './types';
|
||||
import { AccessRestrictionsMap, ExternalTokenHandler } from './types';
|
||||
|
||||
/**
|
||||
* Parses and returns the `accessRestrictions` configuration from an
|
||||
@@ -25,12 +25,12 @@ import { AccessRestriptionsMap } from './types';
|
||||
*/
|
||||
export function readAccessRestrictionsFromConfig(
|
||||
externalAccessEntryConfig: Config,
|
||||
): AccessRestriptionsMap | undefined {
|
||||
): AccessRestrictionsMap | undefined {
|
||||
const configs =
|
||||
externalAccessEntryConfig.getOptionalConfigArray('accessRestrictions') ??
|
||||
[];
|
||||
|
||||
const result: AccessRestriptionsMap = new Map();
|
||||
const result: AccessRestrictionsMap = new Map();
|
||||
for (const config of configs) {
|
||||
const validKeys = ['plugin', 'permission', 'permissionAttribute'];
|
||||
for (const key of config.keys()) {
|
||||
@@ -147,3 +147,37 @@ function readPermissionAttributes(externalAccessEntryConfig: Config) {
|
||||
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an external token handler with the provided implementation.
|
||||
*
|
||||
* This helper function simplifies the creation of external token handlers by
|
||||
* providing type safety and a consistent API. External token handlers are used
|
||||
* to validate tokens from external systems that need to authenticate with Backstage.
|
||||
*
|
||||
* See {@link https://backstage.io/docs/auth/service-to-service-auth#adding-custom-externaltokenhandler | the service-to-service auth docs}
|
||||
* for more information about implementing custom external token handlers.
|
||||
*
|
||||
* @public
|
||||
* @param handler - The external token handler implementation with type, initialize, and verifyToken methods
|
||||
* @returns The same handler instance, typed as ExternalTokenHandler<TContext>
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const customHandler = createExternalTokenHandler({
|
||||
* type: 'custom',
|
||||
* initialize({ options }) {
|
||||
* return { apiKey: options.getString('apiKey') };
|
||||
* },
|
||||
* async verifyToken(token, context) {
|
||||
* // Custom validation logic here
|
||||
* return { subject: 'custom:user' };
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createExternalTokenHandler<TContext>(
|
||||
handler: ExternalTokenHandler<TContext>,
|
||||
): ExternalTokenHandler<TContext> {
|
||||
return handler;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { JWKSHandler } from './jwks';
|
||||
import { jwksTokenHandler } from './jwks';
|
||||
|
||||
// Simplified copy of TokenFactory in @backstage/plugin-auth-backend
|
||||
interface AnyJWK extends Record<string, string> {
|
||||
@@ -101,114 +101,45 @@ describe('JWKSHandler', () => {
|
||||
|
||||
it('verifies token with valid entry', async () => {
|
||||
const validEntry = {
|
||||
options: {
|
||||
url: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithm: 'RS256',
|
||||
issuer: mockBaseUrl,
|
||||
audience: 'backstage',
|
||||
},
|
||||
url: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithm: 'RS256',
|
||||
issuer: mockBaseUrl,
|
||||
audience: 'backstage',
|
||||
};
|
||||
const jwksHandler = new JWKSHandler();
|
||||
|
||||
jwksHandler.add(new ConfigReader(validEntry));
|
||||
const context = jwksTokenHandler.initialize({
|
||||
options: new ConfigReader(validEntry),
|
||||
});
|
||||
|
||||
const token = await factory.issueToken({
|
||||
claims: { sub: mockSubject },
|
||||
});
|
||||
|
||||
const result = await jwksHandler.verifyToken(token);
|
||||
const result = await jwksTokenHandler.verifyToken(token, context);
|
||||
|
||||
expect(result).toEqual({ subject: `external:${mockSubject}` });
|
||||
});
|
||||
|
||||
it('skips invalid entry and continues verification', async () => {
|
||||
const invalidEntry = {
|
||||
options: {
|
||||
url: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithm: 'RS256',
|
||||
issuer: ['fakeIssuer'],
|
||||
audience: ['fakeAud'],
|
||||
},
|
||||
};
|
||||
|
||||
const validEntry = {
|
||||
options: {
|
||||
url: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithm: 'RS256',
|
||||
issuer: ['multiple-issuers', mockBaseUrl],
|
||||
audience: ['multiple-audiences', 'backstage'],
|
||||
},
|
||||
};
|
||||
const jwksHandler = new JWKSHandler();
|
||||
|
||||
jwksHandler.add(new ConfigReader(invalidEntry));
|
||||
jwksHandler.add(new ConfigReader(validEntry));
|
||||
|
||||
const token = await factory.issueToken({
|
||||
claims: { sub: mockSubject },
|
||||
});
|
||||
|
||||
const result = await jwksHandler.verifyToken(token);
|
||||
|
||||
expect(result).toEqual({ subject: `external:${mockSubject}` });
|
||||
});
|
||||
|
||||
it('returns undefined if no valid entry found', async () => {
|
||||
const invalidEntry1 = {
|
||||
options: {
|
||||
url: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithm: 'RS256',
|
||||
issuer: 'wrong',
|
||||
},
|
||||
};
|
||||
|
||||
const invalidEntry2 = {
|
||||
options: {
|
||||
url: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithm: ['HS256'],
|
||||
audience: 'wrong',
|
||||
},
|
||||
};
|
||||
const jwksHandler = new JWKSHandler();
|
||||
|
||||
jwksHandler.add(new ConfigReader(invalidEntry1));
|
||||
jwksHandler.add(new ConfigReader(invalidEntry2));
|
||||
|
||||
const token = await factory.issueToken({
|
||||
claims: { sub: mockSubject },
|
||||
});
|
||||
|
||||
const result = await jwksHandler.verifyToken(token);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects bad config', () => {
|
||||
const jwksHandler = new JWKSHandler();
|
||||
|
||||
expect(() => {
|
||||
jwksHandler.add(
|
||||
new ConfigReader({
|
||||
options: {
|
||||
url: 'https://exampl e.com/jwks',
|
||||
},
|
||||
return jwksTokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
url: 'https://exampl e.com/jwks',
|
||||
}),
|
||||
);
|
||||
});
|
||||
}).toThrow('Illegal JWKS URL, must be a set of non-space characters');
|
||||
expect(() => {
|
||||
jwksHandler.add(
|
||||
new ConfigReader({
|
||||
options: {
|
||||
url: 'https://example.com/jwks\n',
|
||||
},
|
||||
return jwksTokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
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();
|
||||
await expect(handler.verifyToken('ghi')).resolves.toBeUndefined();
|
||||
await expect(
|
||||
jwksTokenHandler.verifyToken('ghi', {} as any),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses custom subject prefix if provided', async () => {
|
||||
@@ -221,41 +152,18 @@ describe('JWKSHandler', () => {
|
||||
subjectPrefix: 'custom-prefix',
|
||||
},
|
||||
};
|
||||
const jwksHandler = new JWKSHandler();
|
||||
|
||||
jwksHandler.add(new ConfigReader(validEntry));
|
||||
const context = jwksTokenHandler.initialize({
|
||||
options: new ConfigReader(validEntry.options),
|
||||
});
|
||||
|
||||
const token = await factory.issueToken({
|
||||
claims: { sub: mockSubject },
|
||||
});
|
||||
|
||||
const result = await jwksHandler.verifyToken(token);
|
||||
const result = await jwksTokenHandler.verifyToken(token, context);
|
||||
|
||||
expect(result).toEqual({
|
||||
subject: `external:${validEntry.options.subjectPrefix}:${mockSubject}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries over access restrictions', async () => {
|
||||
const jwksHandler = new JWKSHandler();
|
||||
jwksHandler.add(
|
||||
new ConfigReader({
|
||||
options: {
|
||||
url: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
},
|
||||
accessRestrictions: [{ plugin: 'scaffolder', permission: 'do.it' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const token = await factory.issueToken({ claims: { sub: mockSubject } });
|
||||
|
||||
await expect(jwksHandler.verifyToken(token)).resolves.toEqual({
|
||||
subject: `external:${mockSubject}`,
|
||||
allAccessRestrictions: new Map(
|
||||
Object.entries({
|
||||
scaffolder: { permissionNames: ['do.it'] },
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+44
-58
@@ -15,51 +15,40 @@
|
||||
*/
|
||||
|
||||
import { jwtVerify, createRemoteJWKSet, JWTVerifyGetKey } from 'jose';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
createExternalTokenHandler,
|
||||
readAccessRestrictionsFromConfig,
|
||||
readStringOrStringArrayFromConfig,
|
||||
} from './helpers';
|
||||
import { AccessRestriptionsMap, TokenHandler } from './types';
|
||||
import { AccessRestrictionsMap } from './types';
|
||||
|
||||
/**
|
||||
* Handles `type: jwks` access.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class JWKSHandler implements TokenHandler {
|
||||
#entries: Array<{
|
||||
algorithms?: string[];
|
||||
audiences?: string[];
|
||||
issuers?: string[];
|
||||
subjectPrefix?: string;
|
||||
url: URL;
|
||||
jwks: JWTVerifyGetKey;
|
||||
allAccessRestrictions?: AccessRestriptionsMap;
|
||||
}> = [];
|
||||
type JWKSTokenContext = {
|
||||
algorithms?: string[];
|
||||
audiences?: string[];
|
||||
issuers?: string[];
|
||||
subjectPrefix?: string;
|
||||
url: URL;
|
||||
jwks: JWTVerifyGetKey;
|
||||
allAccessRestrictions?: AccessRestrictionsMap;
|
||||
};
|
||||
|
||||
add(config: Config) {
|
||||
if (!config.getString('options.url').match(/^\S+$/)) {
|
||||
export const jwksTokenHandler = createExternalTokenHandler<JWKSTokenContext>({
|
||||
type: 'jwks',
|
||||
initialize({ options }): JWKSTokenContext {
|
||||
if (!options.getString('url').match(/^\S+$/)) {
|
||||
throw new Error(
|
||||
'Illegal JWKS URL, must be a set of non-space characters',
|
||||
);
|
||||
}
|
||||
|
||||
const algorithms = readStringOrStringArrayFromConfig(
|
||||
config,
|
||||
'options.algorithm',
|
||||
);
|
||||
const issuers = readStringOrStringArrayFromConfig(config, 'options.issuer');
|
||||
const audiences = readStringOrStringArrayFromConfig(
|
||||
config,
|
||||
'options.audience',
|
||||
);
|
||||
const subjectPrefix = config.getOptionalString('options.subjectPrefix');
|
||||
const url = new URL(config.getString('options.url'));
|
||||
const algorithms = readStringOrStringArrayFromConfig(options, 'algorithm');
|
||||
const issuers = readStringOrStringArrayFromConfig(options, 'issuer');
|
||||
const audiences = readStringOrStringArrayFromConfig(options, 'audience');
|
||||
const subjectPrefix = options.getOptionalString('subjectPrefix');
|
||||
const url = new URL(options.getString('url'));
|
||||
const jwks = createRemoteJWKSet(url);
|
||||
const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
|
||||
|
||||
this.#entries.push({
|
||||
const allAccessRestrictions = readAccessRestrictionsFromConfig(options);
|
||||
return {
|
||||
algorithms,
|
||||
audiences,
|
||||
issuers,
|
||||
@@ -67,33 +56,30 @@ export class JWKSHandler implements TokenHandler {
|
||||
subjectPrefix,
|
||||
url,
|
||||
allAccessRestrictions,
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async verifyToken(token: string) {
|
||||
for (const entry of this.#entries) {
|
||||
try {
|
||||
const {
|
||||
payload: { sub },
|
||||
} = await jwtVerify(token, entry.jwks, {
|
||||
algorithms: entry.algorithms,
|
||||
issuer: entry.issuers,
|
||||
audience: entry.audiences,
|
||||
});
|
||||
async verifyToken(token: string, context: JWKSTokenContext) {
|
||||
try {
|
||||
const {
|
||||
payload: { sub },
|
||||
} = await jwtVerify(token, context.jwks, {
|
||||
algorithms: context.algorithms,
|
||||
issuer: context.issuers,
|
||||
audience: context.audiences,
|
||||
});
|
||||
|
||||
if (sub) {
|
||||
const prefix = entry.subjectPrefix
|
||||
? `external:${entry.subjectPrefix}:`
|
||||
: 'external:';
|
||||
return {
|
||||
subject: `${prefix}${sub}`,
|
||||
allAccessRestrictions: entry.allAccessRestrictions,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
if (sub) {
|
||||
const prefix = context.subjectPrefix
|
||||
? `external:${context.subjectPrefix}:`
|
||||
: 'external:';
|
||||
return {
|
||||
subject: `${prefix}${sub}`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+164
-143
@@ -18,49 +18,35 @@ import { ConfigReader } from '@backstage/config';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { SignJWT, importJWK } from 'jose';
|
||||
import { DateTime } from 'luxon';
|
||||
import { LegacyTokenHandler } from './legacy';
|
||||
import { legacyTokenHandler } from './legacy';
|
||||
|
||||
describe('LegacyTokenHandler', () => {
|
||||
const tokenHandler = new LegacyTokenHandler();
|
||||
const key1 = randomBytes(24);
|
||||
const key2 = randomBytes(24);
|
||||
const key3 = randomBytes(24);
|
||||
const accessRestrictions1 = new Map(
|
||||
Object.entries({
|
||||
scaffolder: {},
|
||||
}),
|
||||
);
|
||||
const accessRestrictions2 = new Map(
|
||||
Object.entries({
|
||||
catalog: { permissionNames: ['catalog.entity.read'] },
|
||||
}),
|
||||
);
|
||||
|
||||
tokenHandler.add(
|
||||
new ConfigReader({
|
||||
options: {
|
||||
secret: key1.toString('base64'),
|
||||
subject: 'key1',
|
||||
},
|
||||
accessRestrictions: [{ plugin: 'scaffolder' }],
|
||||
const tokenHandler = legacyTokenHandler;
|
||||
|
||||
const context1 = tokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
secret: key1.toString('base64'),
|
||||
subject: 'key1',
|
||||
}),
|
||||
);
|
||||
tokenHandler.add(
|
||||
new ConfigReader({
|
||||
options: {
|
||||
secret: key2.toString('base64'),
|
||||
subject: 'key2',
|
||||
},
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
});
|
||||
|
||||
const context2 = tokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
secret: key2.toString('base64'),
|
||||
subject: 'key2',
|
||||
}),
|
||||
);
|
||||
tokenHandler.addOld(
|
||||
new ConfigReader({
|
||||
});
|
||||
|
||||
const contextWithLegacy = tokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
secret: key3.toString('base64'),
|
||||
}),
|
||||
);
|
||||
legacy: true,
|
||||
});
|
||||
|
||||
it('should verify valid tokens', async () => {
|
||||
const token1 = await new SignJWT({
|
||||
@@ -70,9 +56,8 @@ describe('LegacyTokenHandler', () => {
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(key1);
|
||||
|
||||
await expect(tokenHandler.verifyToken(token1)).resolves.toEqual({
|
||||
await expect(tokenHandler.verifyToken(token1, context1)).resolves.toEqual({
|
||||
subject: 'key1',
|
||||
allAccessRestrictions: accessRestrictions1,
|
||||
});
|
||||
|
||||
const token2 = await new SignJWT({
|
||||
@@ -82,9 +67,8 @@ describe('LegacyTokenHandler', () => {
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(key2);
|
||||
|
||||
await expect(tokenHandler.verifyToken(token2)).resolves.toEqual({
|
||||
await expect(tokenHandler.verifyToken(token2, context2)).resolves.toEqual({
|
||||
subject: 'key2',
|
||||
allAccessRestrictions: accessRestrictions2,
|
||||
});
|
||||
|
||||
const token3 = await new SignJWT({
|
||||
@@ -94,7 +78,9 @@ describe('LegacyTokenHandler', () => {
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(key3);
|
||||
|
||||
await expect(tokenHandler.verifyToken(token3)).resolves.toEqual({
|
||||
await expect(
|
||||
tokenHandler.verifyToken(token3, contextWithLegacy),
|
||||
).resolves.toEqual({
|
||||
subject: 'external:backstage-plugin',
|
||||
});
|
||||
});
|
||||
@@ -107,10 +93,18 @@ describe('LegacyTokenHandler', () => {
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(key1);
|
||||
|
||||
await expect(tokenHandler.verifyToken(validToken)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
tokenHandler.verifyToken(validToken, context1),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
tokenHandler.verifyToken(validToken, context2),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
tokenHandler.verifyToken('statickeyblaaa'),
|
||||
tokenHandler.verifyToken('statickeyblaaa', context1),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
tokenHandler.verifyToken('statickeyblaaa', context2),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const randomToken = await new SignJWT({
|
||||
@@ -120,7 +114,10 @@ describe('LegacyTokenHandler', () => {
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(randomBytes(24));
|
||||
await expect(
|
||||
tokenHandler.verifyToken(randomToken),
|
||||
tokenHandler.verifyToken(randomToken, context1),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
tokenHandler.verifyToken(randomToken, context2),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const mockPublicKey = {
|
||||
@@ -144,7 +141,10 @@ describe('LegacyTokenHandler', () => {
|
||||
.sign(await importJWK(mockPrivateKey));
|
||||
|
||||
await expect(
|
||||
tokenHandler.verifyToken(keyWithWrongAlg),
|
||||
tokenHandler.verifyToken(keyWithWrongAlg, context1),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
tokenHandler.verifyToken(keyWithWrongAlg, context2),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -157,130 +157,151 @@ describe('LegacyTokenHandler', () => {
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(key1);
|
||||
|
||||
await expect(tokenHandler.verifyToken(keyWithWrongExp)).rejects.toThrow(
|
||||
/\"exp\" claim must be a number/,
|
||||
);
|
||||
await expect(
|
||||
tokenHandler.verifyToken(keyWithWrongExp, context1),
|
||||
).rejects.toThrow(/\"exp\" claim must be a number/);
|
||||
});
|
||||
|
||||
it('rejects bad config', () => {
|
||||
const handler = new LegacyTokenHandler();
|
||||
const handler = legacyTokenHandler;
|
||||
|
||||
// new style add, bad secrets
|
||||
expect(() =>
|
||||
handler.add(
|
||||
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' } })),
|
||||
).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' } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { secret: 'hasnewline\n', subject: 'ok' },
|
||||
handler.initialize({
|
||||
options: new ConfigReader({
|
||||
_missingsecret: true,
|
||||
subject: 'ok',
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(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 },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'options.subject' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
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' },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal subject, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
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 } }),
|
||||
),
|
||||
).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'] }],
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0].plugin' in 'mock-config', got array, wanted string"`,
|
||||
);
|
||||
|
||||
// old style add
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: 'b2s=' })),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ _missingsecret: true })),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'secret' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: '' })),
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ secret: '', subject: 'ok' }),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'secret' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: 'has spaces' })),
|
||||
handler.initialize({
|
||||
options: new ConfigReader({
|
||||
secret: 'has spaces',
|
||||
subject: 'ok',
|
||||
}),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: 'hasnewline\n' })),
|
||||
handler.initialize({
|
||||
options: new ConfigReader({
|
||||
secret: 'hasnewline\n',
|
||||
subject: 'ok',
|
||||
}),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: 3 })),
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ secret: 3, subject: 'ok' }),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'secret' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
|
||||
// new style add, bad subjects
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({
|
||||
secret: 'b2s=',
|
||||
_missingsubject: true,
|
||||
}),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'subject' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ secret: 'b2s=', subject: '' }),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'subject' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({
|
||||
secret: 'b2s=',
|
||||
subject: 'has spaces',
|
||||
}),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal subject, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({
|
||||
secret: 'b2s=',
|
||||
subject: 'hasnewline\n',
|
||||
}),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal subject, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ secret: 'b2s=', subject: 3 }),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'subject' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
|
||||
// old style add
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ secret: 'b2s=' }),
|
||||
legacy: true,
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ _missingsecret: true }),
|
||||
legacy: true,
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'secret' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ secret: '' }),
|
||||
legacy: true,
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'secret' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ secret: 'has spaces' }),
|
||||
legacy: true,
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ secret: 'hasnewline\n' }),
|
||||
legacy: true,
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ secret: 3 }),
|
||||
legacy: true,
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'secret' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
|
||||
@@ -16,72 +16,56 @@
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { base64url, decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose';
|
||||
import { readAccessRestrictionsFromConfig } from './helpers';
|
||||
import { AccessRestriptionsMap, TokenHandler } from './types';
|
||||
|
||||
/**
|
||||
* Handles `type: legacy` access.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class LegacyTokenHandler implements TokenHandler {
|
||||
#entries = new Array<{
|
||||
key: Uint8Array;
|
||||
result: {
|
||||
subject: string;
|
||||
allAccessRestrictions?: AccessRestriptionsMap;
|
||||
};
|
||||
}>();
|
||||
import { ExternalTokenHandler } from './types';
|
||||
|
||||
add(config: Config) {
|
||||
const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
|
||||
this.#doAdd(
|
||||
config.getString('options.secret'),
|
||||
config.getString('options.subject'),
|
||||
allAccessRestrictions,
|
||||
);
|
||||
}
|
||||
export type LegacyConfigWrapper = {
|
||||
legacy: boolean;
|
||||
config: Config;
|
||||
};
|
||||
|
||||
// used only for the old backend.auth.keys array
|
||||
addOld(config: Config) {
|
||||
// This choice of subject is for compatibility reasons
|
||||
this.#doAdd(config.getString('secret'), 'external:backstage-plugin');
|
||||
}
|
||||
type LegacyTokenHandlerContext = {
|
||||
key: Uint8Array;
|
||||
|
||||
subject: string;
|
||||
};
|
||||
|
||||
type LegacyTokenHandlerOverloaded =
|
||||
ExternalTokenHandler<LegacyTokenHandlerContext> & {
|
||||
initialize(ctx: {
|
||||
options: Config;
|
||||
legacy: true;
|
||||
}): LegacyTokenHandlerContext;
|
||||
};
|
||||
|
||||
export const legacyTokenHandler: LegacyTokenHandlerOverloaded = {
|
||||
type: 'legacy',
|
||||
initialize(ctx: {
|
||||
options: Config;
|
||||
legacy?: true;
|
||||
}): LegacyTokenHandlerContext {
|
||||
const secret = ctx.options.getString('secret');
|
||||
const subject = ctx.legacy
|
||||
? 'external:backstage-plugin'
|
||||
: ctx.options.getString('subject');
|
||||
|
||||
#doAdd(
|
||||
secret: string,
|
||||
subject: string,
|
||||
allAccessRestrictions?: AccessRestriptionsMap,
|
||||
) {
|
||||
if (!secret.match(/^\S+$/)) {
|
||||
throw new Error('Illegal secret, must be a valid base64 string');
|
||||
} else if (!subject.match(/^\S+$/)) {
|
||||
throw new Error('Illegal subject, must be a set of non-space characters');
|
||||
}
|
||||
|
||||
let key: Uint8Array;
|
||||
try {
|
||||
key = base64url.decode(secret);
|
||||
return {
|
||||
key: base64url.decode(secret),
|
||||
subject,
|
||||
};
|
||||
} catch {
|
||||
throw new Error('Illegal secret, must be a valid base64 string');
|
||||
}
|
||||
},
|
||||
|
||||
if (this.#entries.some(e => e.key === key)) {
|
||||
throw new Error(
|
||||
'Legacy externalAccess token was declared more than once',
|
||||
);
|
||||
}
|
||||
|
||||
this.#entries.push({
|
||||
key,
|
||||
result: {
|
||||
subject,
|
||||
allAccessRestrictions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
async verifyToken(token: string, context: LegacyTokenHandlerContext) {
|
||||
// First do a duck typing check to see if it remotely looks like a legacy token
|
||||
try {
|
||||
// We do a fair amount of checking upfront here. Since we aren't certain
|
||||
@@ -102,19 +86,18 @@ export class LegacyTokenHandler implements TokenHandler {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const { key, result } of this.#entries) {
|
||||
try {
|
||||
await jwtVerify(token, key);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
|
||||
throw e;
|
||||
}
|
||||
// Otherwise continue to try the next key
|
||||
try {
|
||||
await jwtVerify(token, context.key);
|
||||
return {
|
||||
subject: context.subject,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// None of the signing keys matched
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,137 +15,146 @@
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { StaticTokenHandler } from './static';
|
||||
import { staticTokenHandler } from './static';
|
||||
|
||||
describe('StaticTokenHandler', () => {
|
||||
it('accepts any of the added list of tokens', async () => {
|
||||
const handler = new StaticTokenHandler();
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { token: 'abcabcabc', subject: 'one' },
|
||||
accessRestrictions: [{ plugin: 'scaffolder' }],
|
||||
const context1 = staticTokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
token: 'abcabcabc',
|
||||
subject: 'one',
|
||||
}),
|
||||
);
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { token: 'defdefdef', subject: 'two' },
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
});
|
||||
const context2 = staticTokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
token: 'defdefdef',
|
||||
subject: 'two',
|
||||
}),
|
||||
);
|
||||
const accessRestrictionsOne = new Map(Object.entries({ scaffolder: {} }));
|
||||
const accessRestrictionsTwo = new Map(
|
||||
Object.entries({
|
||||
catalog: {
|
||||
permissionNames: ['catalog.entity.read'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await expect(handler.verifyToken('abcabcabc')).resolves.toEqual({
|
||||
await expect(
|
||||
staticTokenHandler.verifyToken('abcabcabc', context1),
|
||||
).resolves.toEqual({
|
||||
subject: 'one',
|
||||
allAccessRestrictions: accessRestrictionsOne,
|
||||
});
|
||||
await expect(handler.verifyToken('defdefdef')).resolves.toEqual({
|
||||
await expect(
|
||||
staticTokenHandler.verifyToken('defdefdef', context2),
|
||||
).resolves.toEqual({
|
||||
subject: 'two',
|
||||
allAccessRestrictions: accessRestrictionsTwo,
|
||||
});
|
||||
await expect(handler.verifyToken('ghighighi')).resolves.toBeUndefined();
|
||||
await expect(
|
||||
staticTokenHandler.verifyToken('ghighighi', context1),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('gracefully handles no added tokens', async () => {
|
||||
const handler = new StaticTokenHandler();
|
||||
await expect(handler.verifyToken('ghi')).resolves.toBeUndefined();
|
||||
await expect(
|
||||
staticTokenHandler.verifyToken('ghi', {} as any),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects bad config', () => {
|
||||
const handler = new StaticTokenHandler();
|
||||
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({ options: { _missingtoken: true, subject: 'ok' } }),
|
||||
),
|
||||
staticTokenHandler.initialize({
|
||||
options: new ConfigReader({ _missingtoken: true, subject: 'ok' }),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'options.token' in 'mock-config'"`,
|
||||
`"Missing required config value at 'token' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ options: { token: '', subject: 'ok' } })),
|
||||
staticTokenHandler.initialize({
|
||||
options: new ConfigReader({ token: '', subject: 'ok' }),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'options.token' in 'mock-config', got empty-string, wanted string"`,
|
||||
`"Invalid type in config for key 'token' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
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',
|
||||
},
|
||||
staticTokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
token: 'has spaces',
|
||||
subject: 'ok',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal token, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({ options: { token: 'short', subject: 'ok' } }),
|
||||
),
|
||||
staticTokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
token: 'hasnewlinebutislongenough\n',
|
||||
subject: 'ok',
|
||||
}),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal token, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
staticTokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
token: 'short',
|
||||
subject: 'ok',
|
||||
}),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal token, must be at least 8 characters length"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ options: { token: 3, subject: 'ok' } })),
|
||||
staticTokenHandler.initialize({
|
||||
options: new ConfigReader({ token: 3, subject: 'ok' }),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'options.token' in 'mock-config', got number, wanted string"`,
|
||||
`"Invalid type in config for key 'token' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { token: 'validtoken', _missingsubject: true },
|
||||
staticTokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
token: 'validtoken',
|
||||
_missingsubject: true,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'options.subject' in 'mock-config'"`,
|
||||
`"Missing required config value at 'subject' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({ options: { token: 'validtoken', subject: '' } }),
|
||||
),
|
||||
staticTokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
token: 'validtoken',
|
||||
subject: '',
|
||||
}),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'options.subject' in 'mock-config', got empty-string, wanted string"`,
|
||||
`"Invalid type in config for key 'subject' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { token: 'validtoken', subject: 'has spaces' },
|
||||
staticTokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
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' },
|
||||
staticTokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
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 } }),
|
||||
),
|
||||
staticTokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
token: 'validtoken',
|
||||
subject: 3,
|
||||
}),
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'options.subject' in 'mock-config', got number, wanted string"`,
|
||||
`"Invalid type in config for key 'subject' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,29 +15,18 @@
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { readAccessRestrictionsFromConfig } from './helpers';
|
||||
import { AccessRestriptionsMap, TokenHandler } from './types';
|
||||
import { createExternalTokenHandler } from './helpers';
|
||||
|
||||
const MIN_TOKEN_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* Handles `type: static` access.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class StaticTokenHandler implements TokenHandler {
|
||||
#entries = new Map<
|
||||
string,
|
||||
{
|
||||
subject: string;
|
||||
allAccessRestrictions?: AccessRestriptionsMap;
|
||||
}
|
||||
>();
|
||||
|
||||
add(config: Config) {
|
||||
const token = config.getString('options.token');
|
||||
const subject = config.getString('options.subject');
|
||||
const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
|
||||
export const staticTokenHandler = createExternalTokenHandler<{
|
||||
token: string;
|
||||
subject: string;
|
||||
}>({
|
||||
type: 'static',
|
||||
initialize(ctx: { options: Config }): { token: string; subject: string } {
|
||||
const token = ctx.options.getString('token');
|
||||
const subject = ctx.options.getString('subject');
|
||||
|
||||
if (!token.match(/^\S+$/)) {
|
||||
throw new Error('Illegal token, must be a set of non-space characters');
|
||||
@@ -47,16 +36,17 @@ export class StaticTokenHandler implements TokenHandler {
|
||||
);
|
||||
} else if (!subject.match(/^\S+$/)) {
|
||||
throw new Error('Illegal subject, must be a set of non-space characters');
|
||||
} else if (this.#entries.has(token)) {
|
||||
throw new Error(
|
||||
'Static externalAccess token was declared more than once',
|
||||
);
|
||||
}
|
||||
|
||||
this.#entries.set(token, { subject, allAccessRestrictions });
|
||||
}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
return this.#entries.get(token);
|
||||
}
|
||||
}
|
||||
return { token, subject };
|
||||
},
|
||||
async verifyToken(
|
||||
token: string,
|
||||
context: { token: string; subject: string },
|
||||
) {
|
||||
if (token === context.token) {
|
||||
return { subject: context.subject };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,20 +15,23 @@
|
||||
*/
|
||||
|
||||
import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import type { Config } from '@backstage/config';
|
||||
|
||||
export type AccessRestriptionsMap = Map<
|
||||
export type AccessRestrictionsMap = Map<
|
||||
string, // plugin ID
|
||||
BackstagePrincipalAccessRestrictions
|
||||
>;
|
||||
|
||||
export interface TokenHandler {
|
||||
add(options: Config): void;
|
||||
verifyToken(token: string): Promise<
|
||||
| {
|
||||
subject: string;
|
||||
allAccessRestrictions?: AccessRestriptionsMap;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
/**
|
||||
* @public
|
||||
* This interface is used to handle external tokens.
|
||||
* It is used by the auth service to verify tokens and extract the subject.
|
||||
*/
|
||||
export interface ExternalTokenHandler<TContext> {
|
||||
type: string;
|
||||
initialize(ctx: { options: Config }): TContext;
|
||||
verifyToken(
|
||||
token: string,
|
||||
ctx: TContext,
|
||||
): Promise<{ subject: string } | undefined>;
|
||||
}
|
||||
|
||||
@@ -19,4 +19,10 @@ export {
|
||||
pluginTokenHandlerDecoratorServiceRef,
|
||||
} from './authServiceFactory';
|
||||
|
||||
export { createExternalTokenHandler } from './external/helpers';
|
||||
|
||||
export { externalTokenHandlersServiceRef } from './external/ExternalAuthTokenHandler';
|
||||
|
||||
export type { ExternalTokenHandler } from './external/types';
|
||||
|
||||
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