backend-defaults: Add support for decoration of the default PluginTokenHandler

Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Signed-off-by: Johan Haals <johan.haals@gmail.com>
This commit is contained in:
Johan Haals
2024-11-21 11:32:00 +01:00
parent 4353dd41d2
commit 8863b3806e
8 changed files with 152 additions and 20 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': patch
---
Export `PluginTokenHandler` and `pluginTokenHandlerDecoratorServiceRef` to allow for custom decoration of the plugin token handler without having to re-implement the entire handler.
@@ -5,6 +5,7 @@
```ts
import { AuthService } from '@backstage/backend-plugin-api';
import { ServiceFactory } from '@backstage/backend-plugin-api';
import { ServiceRef } from '@backstage/backend-plugin-api';
// @public
export const authServiceFactory: ServiceFactory<
@@ -13,5 +14,35 @@ export const authServiceFactory: ServiceFactory<
'singleton'
>;
// @public
export interface PluginTokenHandler {
// (undocumented)
issueToken(options: {
pluginId: string;
targetPluginId: string;
onBehalfOf?: {
limitedUserToken: string;
expiresAt: Date;
};
}): Promise<{
token: string;
}>;
// (undocumented)
verifyToken(token: string): Promise<
| {
subject: string;
limitedUserToken?: string;
}
| undefined
>;
}
// @public
export const pluginTokenHandlerDecoratorServiceRef: ServiceRef<
(defaultImplementation: PluginTokenHandler) => PluginTokenHandler,
'plugin',
'singleton'
>;
// (No @packageDocumentation comment for this package)
```
@@ -169,7 +169,10 @@ export class DefaultAuthService implements AuthService {
return this.pluginTokenHandler.issueToken({
pluginId: this.pluginId,
targetPluginId,
onBehalfOf,
onBehalfOf: {
limitedUserToken: onBehalfOf.token,
expiresAt: onBehalfOf.expiresAt,
},
});
}
default:
@@ -19,12 +19,17 @@ import {
mockServices,
registerMswTestHooks,
} from '@backstage/backend-test-utils';
import { authServiceFactory } from './authServiceFactory';
import {
authServiceFactory,
pluginTokenHandlerDecoratorServiceRef,
} from './authServiceFactory';
import { base64url, decodeJwt } from 'jose';
import { discoveryServiceFactory } from '../discovery';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { toInternalBackstageCredentials } from './helpers';
import { PluginTokenHandler } from './plugin/PluginTokenHandler';
import { createServiceFactory } from '@backstage/backend-plugin-api';
const server = setupServer();
@@ -407,4 +412,41 @@ describe('authServiceFactory', () => {
principal: { subject: 'unlimited-static-subject' },
});
});
describe('decorate PluginTokenHandler', () => {
it('should allow custom logic to be injected into the plugin token handler', async () => {
const customLogic = jest.fn();
const customPluginTokenHandler = createServiceFactory({
service: pluginTokenHandlerDecoratorServiceRef,
deps: {},
async factory() {
return (defaultImplementation: PluginTokenHandler) =>
new (class CustomHandler implements PluginTokenHandler {
verifyToken(
token: string,
): Promise<
{ subject: string; limitedUserToken?: string } | undefined
> {
customLogic(token);
// check if token is iam/auth or basicAuth, verify.
return defaultImplementation.verifyToken(token);
}
issueToken(options: {
pluginId: string;
targetPluginId: string;
limitedUserToken?: { token: string; expiresAt: Date };
}): Promise<{ token: string }> {
return defaultImplementation.issueToken(options);
}
})();
},
});
const tester = ServiceFactoryTester.from(authServiceFactory, {
dependencies: [...mockDeps, customPluginTokenHandler],
});
const searchAuth = await tester.getSubject('search');
searchAuth.authenticate('unlimited-static-token');
expect(customLogic).toHaveBeenCalledWith('unlimited-static-token');
});
});
});
@@ -17,13 +17,35 @@
import {
coreServices,
createServiceFactory,
createServiceRef,
} from '@backstage/backend-plugin-api';
import { DefaultAuthService } from './DefaultAuthService';
import { ExternalTokenHandler } from './external/ExternalTokenHandler';
import { PluginTokenHandler } from './plugin/PluginTokenHandler';
import {
DefaultPluginTokenHandler,
PluginTokenHandler,
} from './plugin/PluginTokenHandler';
import { createPluginKeySource } from './plugin/keys/createPluginKeySource';
import { UserTokenHandler } from './user/UserTokenHandler';
/**
* @public
* This service is used to decorate the default plugin token handler with custom logic.
*/
export const pluginTokenHandlerDecoratorServiceRef = createServiceRef<
(defaultImplementation: PluginTokenHandler) => PluginTokenHandler
>({
id: 'core.auth.pluginTokenHandlerDecorator',
defaultFactory: async service =>
createServiceFactory({
service,
deps: {},
factory: async () => {
return impl => impl;
},
}),
});
/**
* Handles token authentication and credentials management.
*
@@ -41,8 +63,16 @@ export const authServiceFactory = createServiceFactory({
discovery: coreServices.discovery,
plugin: coreServices.pluginMetadata,
database: coreServices.database,
pluginTokenHandlerDecorator: pluginTokenHandlerDecoratorServiceRef,
},
async factory({ config, discovery, plugin, logger, database }) {
async factory({
config,
discovery,
plugin,
logger,
database,
pluginTokenHandlerDecorator,
}) {
const disableDefaultAuthPolicy =
config.getOptionalBoolean(
'backend.auth.dangerouslyDisableDefaultAuthPolicy',
@@ -61,13 +91,15 @@ export const authServiceFactory = createServiceFactory({
discovery,
});
const pluginTokens = PluginTokenHandler.create({
ownPluginId: plugin.getId(),
logger,
keySource,
keyDuration,
discovery,
});
const pluginTokens = pluginTokenHandlerDecorator(
DefaultPluginTokenHandler.create({
ownPluginId: plugin.getId(),
logger,
keySource,
keyDuration,
discovery,
}),
);
const externalTokens = ExternalTokenHandler.create({
ownPluginId: plugin.getId(),
@@ -14,4 +14,9 @@
* limitations under the License.
*/
export { authServiceFactory } from './authServiceFactory';
export {
authServiceFactory,
pluginTokenHandlerDecoratorServiceRef,
} from './authServiceFactory';
export type { PluginTokenHandler } from './plugin/PluginTokenHandler';
@@ -15,7 +15,7 @@
*/
import { mockServices } from '@backstage/backend-test-utils';
import { PluginTokenHandler } from './PluginTokenHandler';
import { DefaultPluginTokenHandler } from './PluginTokenHandler';
import { decodeJwt } from 'jose';
describe('PluginTokenHandler', () => {
@@ -42,7 +42,7 @@ describe('PluginTokenHandler', () => {
});
const getKeyMock = jest.fn(async () => mockPrivateKey);
const handler = PluginTokenHandler.create({
const handler = DefaultPluginTokenHandler.create({
discovery: mockServices.discovery(),
keyDuration: { seconds: 10 },
logger: mockServices.logger.mock(),
@@ -22,7 +22,6 @@ import { tokenTypes } from '@backstage/plugin-auth-node';
import { JwksClient } from '../JwksClient';
import { HumanDuration, durationToMilliseconds } from '@backstage/types';
import { PluginKeySource } from './keys/types';
import fetch from 'node-fetch';
const SECONDS_IN_MS = 1000;
@@ -45,7 +44,22 @@ type Options = {
algorithm?: string;
};
export class PluginTokenHandler {
/**
* @public
* PluginTokenHandler is responsible for issuing and verifying tokens
*/
export interface PluginTokenHandler {
verifyToken(
token: string,
): Promise<{ subject: string; limitedUserToken?: string } | undefined>;
issueToken(options: {
pluginId: string;
targetPluginId: string;
onBehalfOf?: { limitedUserToken: string; expiresAt: Date };
}): Promise<{ token: string }>;
}
export class DefaultPluginTokenHandler implements PluginTokenHandler {
private jwksMap = new Map<string, JwksClient>();
// Tracking state for isTargetPluginSupported
@@ -53,7 +67,7 @@ export class PluginTokenHandler {
private targetPluginInflightChecks = new Map<string, Promise<boolean>>();
static create(options: Options) {
return new PluginTokenHandler(
return new DefaultPluginTokenHandler(
options.logger,
options.ownPluginId,
options.keySource,
@@ -115,7 +129,7 @@ export class PluginTokenHandler {
async issueToken(options: {
pluginId: string;
targetPluginId: string;
onBehalfOf?: { token: string; expiresAt: Date };
onBehalfOf?: { limitedUserToken: string; expiresAt: Date };
}): Promise<{ token: string }> {
const { pluginId, targetPluginId, onBehalfOf } = options;
const key = await this.keySource.getPrivateSigningKey();
@@ -131,7 +145,7 @@ export class PluginTokenHandler {
)
: ourExp;
const claims = { sub, aud, iat, exp, obo: onBehalfOf?.token };
const claims = { sub, aud, iat, exp, obo: onBehalfOf?.limitedUserToken };
const token = await new SignJWT(claims)
.setProtectedHeader({
typ: tokenTypes.plugin.typParam,
@@ -180,7 +194,7 @@ export class PluginTokenHandler {
this.supportedTargetPlugins.add(targetPluginId);
return true;
} catch (error) {
} catch (error: any) {
this.logger.error('Unexpected failure for target JWKS check', error);
return false;
} finally {