From 8863b3806e9f0b7c1304c42202ba606dd6847cb4 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 21 Nov 2024 11:32:00 +0100 Subject: [PATCH 1/5] backend-defaults: Add support for decoration of the default PluginTokenHandler Co-authored-by: Patrik Oldsberg Signed-off-by: Johan Haals --- .changeset/cuddly-chicken-wink.md | 5 ++ packages/backend-defaults/report-auth.api.md | 31 ++++++++++++ .../entrypoints/auth/DefaultAuthService.ts | 5 +- .../auth/authServiceFactory.test.ts | 44 +++++++++++++++- .../entrypoints/auth/authServiceFactory.ts | 50 +++++++++++++++---- .../src/entrypoints/auth/index.ts | 7 ++- .../auth/plugin/PluginTokenHandler.test.ts | 4 +- .../auth/plugin/PluginTokenHandler.ts | 26 +++++++--- 8 files changed, 152 insertions(+), 20 deletions(-) create mode 100644 .changeset/cuddly-chicken-wink.md 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/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..6f0578e99e 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,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'); + }); + }); }); 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..00489851b0 100644 --- a/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts +++ b/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts @@ -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(); // 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, @@ -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 { From 3052ecd97d7a3f46fde051fdff62e742883955c1 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 22 Nov 2024 09:43:37 +0100 Subject: [PATCH 2/5] Update packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Johan Haals Co-authored-by: Fredrik Adelöw Signed-off-by: Johan Haals --- .../src/entrypoints/auth/plugin/PluginTokenHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts b/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts index 00489851b0..22331930aa 100644 --- a/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts +++ b/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts @@ -46,7 +46,7 @@ type Options = { /** * @public - * PluginTokenHandler is responsible for issuing and verifying tokens + * Issues and verifies {@link https://backstage.io/docs/auth/service-to-service-auth | service-to-service tokens}. */ export interface PluginTokenHandler { verifyToken( From 062eaf3f75085511a641028f05329eadd40a2d45 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 22 Nov 2024 10:02:54 +0100 Subject: [PATCH 3/5] chore: Add documentation Signed-off-by: Johan Haals --- docs/auth/service-to-service-auth.md | 45 +++++++++++++++++++ .../auth/authServiceFactory.test.ts | 1 - .../auth/plugin/PluginTokenHandler.ts | 7 +-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index 13516a1117..fddfe89c9a 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -412,3 +412,48 @@ 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 `PluginTokenHandler`. +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 (class CustomHandler implements PluginTokenHandler { + verifyToken( + token: string, + ): Promise<{ subject: string; limitedUserToken?: string } | undefined> { + // custom logic here + if (isMyCustomToken(token)) { + return { subject: 'custom-subject' }; + } + return defaultImplementation.verifyToken(token); + } + issueToken(options: { + pluginId: string; + targetPluginId: string; + limitedUserToken?: { token: string; expiresAt: Date }; + }): Promise<{ token: string }> { + return defaultImplementation.issueToken(options); + } + })(); + }, +}); +``` diff --git a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts index 6f0578e99e..38b8862a4c 100644 --- a/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/auth/authServiceFactory.test.ts @@ -428,7 +428,6 @@ describe('authServiceFactory', () => { { subject: string; limitedUserToken?: string } | undefined > { customLogic(token); - // check if token is iam/auth or basicAuth, verify. return defaultImplementation.verifyToken(token); } issueToken(options: { diff --git a/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts b/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts index 22331930aa..281dca6d9e 100644 --- a/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts +++ b/packages/backend-defaults/src/entrypoints/auth/plugin/PluginTokenHandler.ts @@ -16,7 +16,7 @@ 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'; @@ -46,7 +46,7 @@ type Options = { /** * @public - * Issues and verifies {@link https://backstage.io/docs/auth/service-to-service-auth | service-to-service tokens}. + * Issues and verifies {@link https://backstage.iceio/docs/auth/service-to-service-auth | service-to-service tokens}. */ export interface PluginTokenHandler { verifyToken( @@ -194,7 +194,8 @@ export class DefaultPluginTokenHandler implements PluginTokenHandler { this.supportedTargetPlugins.add(targetPluginId); return true; - } catch (error: any) { + } catch (error) { + assertError(error); this.logger.error('Unexpected failure for target JWKS check', error); return false; } finally { From aea3fcf0348f211bf36b322ffa8316502d833878 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 22 Nov 2024 10:43:25 +0100 Subject: [PATCH 4/5] simplify example Signed-off-by: Johan Haals --- docs/auth/service-to-service-auth.md | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index fddfe89c9a..6bebd4e8c4 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -436,24 +436,7 @@ const decoratedPluginTokenHandler = createServiceFactory({ deps: {}, async factory() { return (defaultImplementation: PluginTokenHandler) => - new (class CustomHandler implements PluginTokenHandler { - verifyToken( - token: string, - ): Promise<{ subject: string; limitedUserToken?: string } | undefined> { - // custom logic here - if (isMyCustomToken(token)) { - return { subject: 'custom-subject' }; - } - return defaultImplementation.verifyToken(token); - } - issueToken(options: { - pluginId: string; - targetPluginId: string; - limitedUserToken?: { token: string; expiresAt: Date }; - }): Promise<{ token: string }> { - return defaultImplementation.issueToken(options); - } - })(); + new CustomTokenHandler(defaultImplementation); }, }); ``` From 3f11a8e0875d85a93c9cc1d341428fc580fcf09a Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 22 Nov 2024 10:44:13 +0100 Subject: [PATCH 5/5] Update docs/auth/service-to-service-auth.md Signed-off-by: Johan Haals Co-authored-by: Patrik Oldsberg Signed-off-by: Johan Haals --- docs/auth/service-to-service-auth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index 6bebd4e8c4..126b136d0b 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -415,7 +415,7 @@ 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 `PluginTokenHandler`. +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: