diff --git a/.changeset/cuddly-chicken-wink.md b/.changeset/cuddly-chicken-wink.md new file mode 100644 index 0000000000..79843ba64d --- /dev/null +++ b/.changeset/cuddly-chicken-wink.md @@ -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. diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index 13516a1117..126b136d0b 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -412,3 +412,31 @@ Each entry has one or more of the following fields: # Also supports the shorthand form: # action: create, read ``` + +## 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. +This is particularly useful when you want to add additional logic to the handler, such as logging or metrics or custom token validation. + +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. + +- `verifyToken`: This method is used to verify a token. It takes in the token as an argument and returns a promise that resolves to an object containing the subject of the token and an optional limited user token. + +```ts +import { + PluginTokenHandler, + pluginTokenHandlerDecoratorServiceRef, +} from '@backstage/backend-defaults/auth'; +import { createServiceFactory } from '@backstage/backend-plugin-api'; + +const decoratedPluginTokenHandler = createServiceFactory({ + service: pluginTokenHandlerDecoratorServiceRef, + deps: {}, + async factory() { + return (defaultImplementation: PluginTokenHandler) => + new CustomTokenHandler(defaultImplementation); + }, +}); +``` diff --git a/packages/backend-defaults/report-auth.api.md b/packages/backend-defaults/report-auth.api.md index 2f201e4573..1883e65a6a 100644 --- a/packages/backend-defaults/report-auth.api.md +++ b/packages/backend-defaults/report-auth.api.md @@ -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) ``` diff --git a/packages/backend-defaults/src/entrypoints/auth/DefaultAuthService.ts b/packages/backend-defaults/src/entrypoints/auth/DefaultAuthService.ts index 05d62a54a2..a81a99fec7 100644 --- a/packages/backend-defaults/src/entrypoints/auth/DefaultAuthService.ts +++ b/packages/backend-defaults/src/entrypoints/auth/DefaultAuthService.ts @@ -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: diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts index cb672ed7f1..38b8862a4c 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts @@ -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,40 @@ 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); + 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'); + }); + }); }); diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts index 34071ca541..129f0a8c95 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.ts @@ -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(), diff --git a/packages/backend-defaults/src/entrypoints/auth/index.ts b/packages/backend-defaults/src/entrypoints/auth/index.ts index 1b55d46a83..e48926f0ce 100644 --- a/packages/backend-defaults/src/entrypoints/auth/index.ts +++ b/packages/backend-defaults/src/entrypoints/auth/index.ts @@ -14,4 +14,9 @@ * limitations under the License. */ -export { authServiceFactory } from './authServiceFactory'; +export { + authServiceFactory, + pluginTokenHandlerDecoratorServiceRef, +} from './authServiceFactory'; + +export type { PluginTokenHandler } from './plugin/PluginTokenHandler'; diff --git a/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.test.ts b/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.test.ts index ce470eff04..558bc5d0da 100644 --- a/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.test.ts @@ -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(), diff --git a/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts b/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts index 4bea74e3af..281dca6d9e 100644 --- a/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts +++ b/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts @@ -16,13 +16,12 @@ import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; import { decodeJwt, importJWK, SignJWT, decodeProtectedHeader } from 'jose'; -import { AuthenticationError } from '@backstage/errors'; +import { assertError, AuthenticationError } from '@backstage/errors'; import { jwtVerify } from 'jose'; 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 + * Issues and verifies {@link https://backstage.iceio/docs/auth/service-to-service-auth | service-to-service 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(); // Tracking state for isTargetPluginSupported @@ -53,7 +67,7 @@ export class PluginTokenHandler { private targetPluginInflightChecks = new Map>(); 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, @@ -181,6 +195,7 @@ export class PluginTokenHandler { this.supportedTargetPlugins.add(targetPluginId); return true; } catch (error) { + assertError(error); this.logger.error('Unexpected failure for target JWKS check', error); return false; } finally {