From 9920e8f0d54af22014c70843705d6003d7cd76f3 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 5 Apr 2024 14:31:46 +0200 Subject: [PATCH] feat: add support for service obo tokens Signed-off-by: Camila Belo Co-authored-by: Patrik Oldsberg Co-authored-by: Vincenzo Scamporlino --- .../auth/PluginTokenHandler.ts | 22 ++- .../implementations/auth/UserTokenHandler.ts | 9 + .../auth/authServiceFactory.test.ts | 173 +++++++++++++++++- .../auth/authServiceFactory.ts | 48 ++++- 4 files changed, 233 insertions(+), 19 deletions(-) diff --git a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts index 2872e7f1f9..14315bbf78 100644 --- a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts @@ -79,7 +79,9 @@ export class PluginTokenHandler { readonly discovery: DiscoveryService, ) {} - async verifyToken(token: string): Promise<{ subject: string } | undefined> { + async verifyToken( + token: string, + ): Promise<{ subject: string; limitedUserToken?: string } | undefined> { try { const { typ } = decodeProtectedHeader(token); if (typ !== tokenTypes.plugin.typParam) { @@ -102,7 +104,7 @@ export class PluginTokenHandler { const jwksClient = await this.getJwksClient(pluginId); await jwksClient.refreshKeyStore(token); // TODO(Rugvip): Refactor so that this isn't needed - const { payload } = await jwtVerify<{ sub: string }>( + const { payload } = await jwtVerify<{ sub: string; obo?: string }>( token, jwksClient.getKey, { @@ -114,21 +116,25 @@ export class PluginTokenHandler { throw new AuthenticationError('Invalid plugin token', e); }); - return { subject: payload.sub }; + return { subject: `plugin:${payload.sub}`, limitedUserToken: payload.obo }; } async issueToken(options: { pluginId: string; targetPluginId: string; + onBehalfOf?: { token: string; expiresAt: Date }; }): Promise<{ token: string }> { + const { pluginId, targetPluginId, onBehalfOf } = options; const key = await this.getKey(); - const sub = options.pluginId; - const aud = options.targetPluginId; + const sub = pluginId; + const aud = targetPluginId; const iat = Math.floor(Date.now() / 1000); - const exp = iat + this.keyDurationSeconds; + const exp = onBehalfOf + ? Math.floor(onBehalfOf.expiresAt.getTime() / 1000) + : iat + this.keyDurationSeconds; - const claims = { sub, aud, iat, exp }; + const claims = { sub, aud, iat, exp, obo: onBehalfOf?.token }; const token = await new SignJWT(claims) .setProtectedHeader({ typ: tokenTypes.plugin.typParam, @@ -197,7 +203,7 @@ export class PluginTokenHandler { // Double check that the target plugin has a valid JWKS endpoint, otherwise avoid creating a remote key set if (!(await this.isTargetPluginSupported(pluginId))) { throw new AuthenticationError( - 'Target plugin does not support self-signed tokens', + `Received a plugin token where the source '${pluginId}' plugin unexpectedly does not have a JWKS endpoint`, ); } diff --git a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts index e1223d3bfb..10121fe450 100644 --- a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts @@ -151,4 +151,13 @@ export class UserTokenHandler { return { token: limitedUserToken, expiresAt: new Date(payload.exp * 1000) }; } + + isLimitedUserToken(token: string): boolean { + try { + const { typ } = decodeProtectedHeader(token); + return typ === tokenTypes.limitedUser.typParam; + } catch { + return false; + } + } } diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts index 07193909ab..f1e5e836e1 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts @@ -22,6 +22,7 @@ import { import { InternalBackstageCredentials, authServiceFactory, + toInternalBackstageCredentials, } from './authServiceFactory'; import { base64url, decodeJwt } from 'jose'; import { discoveryServiceFactory } from '../discovery'; @@ -56,7 +57,14 @@ describe('authServiceFactory', () => { jest.useRealTimers(); }); - it('should authenticate issued tokens', async () => { + it('should authenticate issued tokens with legacy auth', async () => { + server.use( + rest.get( + 'http://localhost:7007/api/catalog/.backstage/auth/v1/jwks.json', + (_req, res, ctx) => res(ctx.status(404)), + ), + ); + const tester = ServiceFactoryTester.from(authServiceFactory, { dependencies: mockDeps, }); @@ -87,7 +95,53 @@ describe('authServiceFactory', () => { ); }); - it('should forward user tokens', async () => { + it('should authenticate issued tokens with new auth', async () => { + const tester = ServiceFactoryTester.from(authServiceFactory, { + dependencies: mockDeps, + }); + + const searchAuth = await tester.get('search'); + const catalogAuth = await tester.get('catalog'); + + server.use( + rest.get( + 'http://localhost:7007/api/catalog/.backstage/auth/v1/jwks.json', + async (_req, res, ctx) => + res(ctx.json(await catalogAuth.listPublicServiceKeys())), + ), + rest.get( + 'http://localhost:7007/api/search/.backstage/auth/v1/jwks.json', + async (_req, res, ctx) => + res(ctx.json(await searchAuth.listPublicServiceKeys())), + ), + ); + + const { token: searchToken } = await searchAuth.getPluginRequestToken({ + onBehalfOf: await searchAuth.getOwnServiceCredentials(), + targetPluginId: 'catalog', + }); + + await expect(searchAuth.authenticate(searchToken)).rejects.toThrow( + 'Invalid plugin token', + ); + await expect(catalogAuth.authenticate(searchToken)).resolves.toEqual( + expect.objectContaining({ + principal: { + type: 'service', + subject: 'plugin:search', + }, + }), + ); + }); + + it('should forward user token if target plugin does not support new auth service', async () => { + server.use( + rest.get( + 'http://localhost:7007/api/permission/.backstage/auth/v1/jwks.json', + (_req, res, ctx) => res(ctx.status(404)), + ), + ); + const tester = ServiceFactoryTester.from(authServiceFactory, { dependencies: mockDeps, }); @@ -106,12 +160,19 @@ describe('authServiceFactory', () => { userEntityRef: 'user:default/alice', }, } as InternalBackstageCredentials, - targetPluginId: 'catalog', + targetPluginId: 'permission', }), ).resolves.toEqual({ token: 'alice-token' }); }); - it('should not forward service tokens', async () => { + it('should issue a new service token with token manager if target plugin does not support new auth service', async () => { + server.use( + rest.get( + 'http://localhost:7007/api/permission/.backstage/auth/v1/jwks.json', + (_req, res, ctx) => res(ctx.status(404)), + ), + ); + const tester = ServiceFactoryTester.from(authServiceFactory, { dependencies: mockDeps, }); @@ -129,7 +190,7 @@ describe('authServiceFactory', () => { subject: 'external:upstream-service', }, } as InternalBackstageCredentials, - targetPluginId: 'catalog', + targetPluginId: 'permission', }); expect(decodeJwt(token)).toEqual( @@ -224,4 +285,106 @@ describe('authServiceFactory', () => { new Date(expectedExpiresAt * 1000), ); }); + + it('should issue service on-behalf-of user tokens', async () => { + const tester = ServiceFactoryTester.from(authServiceFactory, { + dependencies: mockDeps, + }); + + const searchAuth = await tester.get('search'); + const catalogAuth = await tester.get('catalog'); + const permissionAuth = await tester.get('permission'); + + server.use( + rest.get( + 'http://localhost:7007/api/auth/.well-known/jwks.json', + (_req, res, ctx) => + res( + ctx.json({ + keys: [ + { + kty: 'EC', + x: '78-Ei1H3nKM23ZpGMMzte2mVoYCcnfnSiLTm1P7vZM0', + y: 'Z9-PjG_EU598tLLUc2f8sCqxT7bjs8WpoV-lHm9GJHY', + crv: 'P-256', + kid: '8d01c3db-56f9-45f0-86dd-05b3c835b3d3', + alg: 'ES256', + }, + ], + }), + ), + ), + rest.get( + 'http://localhost:7007/api/catalog/.backstage/auth/v1/jwks.json', + async (_req, res, ctx) => + res(ctx.json(await catalogAuth.listPublicServiceKeys())), + ), + rest.get( + 'http://localhost:7007/api/search/.backstage/auth/v1/jwks.json', + async (_req, res, ctx) => + res(ctx.json(await searchAuth.listPublicServiceKeys())), + ), + rest.get( + 'http://localhost:7007/api/permission/.backstage/auth/v1/jwks.json', + async (_req, res, ctx) => + res(ctx.json(await permissionAuth.listPublicServiceKeys())), + ), + rest.get( + 'http://localhost:7007/api/kubernetes/.backstage/auth/v1/jwks.json', + (_req, res, ctx) => res(ctx.status(404)), + ), + ); + + const expectedIssuedAt = 1712071714; + const expectedExpiresAt = 1712075314; + + jest.useFakeTimers({ + now: expectedIssuedAt * 1000 + 600_000, + }); + + const fullToken = + 'eyJ0eXAiOiJ2bmQuYmFja3N0YWdlLnVzZXIiLCJhbGciOiJFUzI1NiIsImtpZCI6IjhkMDFjM2RiLTU2ZjktNDVmMC04NmRkLTA1YjNjODM1YjNkMyJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJ1c2VyOmRldmVsb3BtZW50L2d1ZXN0IiwiZW50IjpbInVzZXI6ZGV2ZWxvcG1lbnQvZ3Vlc3QiLCJncm91cDpkZWZhdWx0L3RlYW0tYSJdLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE3MTIwNzE3MTQsImV4cCI6MTcxMjA3NTMxNCwidWlwIjoiMDFBUUJfSWpHTXRWc2gyWmgzZEg1NXhOX29pSVlhQ1F3ODJjeDZ5M1BQMXlpTjM4eGMzMVpMS2U0YVNDQlJTTy10cjFzZFUzT29ELUxJYV8tNV9RVUEifQ.mjIrZGqbZ2t68fS4U3crlGw-bYJZnMlhMHf-YL7q_u1HfaLr4NMTcHkxdnNS2wfJxCmUBxRfUS8b3nSAKsxcHA'; + + const credentials = await searchAuth.authenticate(fullToken); + if (!searchAuth.isPrincipal(credentials, 'user')) { + throw new Error('not a user principal'); + } + const { token: limitedToken } = await searchAuth.getLimitedUserToken( + credentials, + ); + + const { token: oboToken } = await searchAuth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: 'catalog', + }); + expect(oboToken).not.toBe(fullToken); + expect(decodeJwt(oboToken).obo).toBe(limitedToken); + expect(decodeJwt(oboToken).exp).toBe(expectedExpiresAt); + + const oboCredentials = await catalogAuth.authenticate(oboToken); + if (!catalogAuth.isPrincipal(oboCredentials, 'user')) { + throw new Error('obo credential is not a user principal'); + } + expect(oboCredentials.principal.userEntityRef).toBe( + 'user:development/guest', + ); + expect(toInternalBackstageCredentials(oboCredentials).token).toBe( + limitedToken, + ); + + const { token: oboToken2 } = await catalogAuth.getPluginRequestToken({ + onBehalfOf: oboCredentials, + targetPluginId: 'permission', + }); + expect(decodeJwt(oboToken2).obo).toBe(limitedToken); + + await expect( + catalogAuth.getPluginRequestToken({ + onBehalfOf: oboCredentials, + targetPluginId: 'kubernetes', + }), + ).rejects.toThrow( + "Unable to call 'kubernetes' plugin on behalf of user, because the target plugin does not support on-behalf-of tokens", + ); + }); }); diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index f6dd2902b9..6a5a1a4c24 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -124,6 +124,21 @@ class DefaultAuthService implements AuthService { async authenticate(token: string): Promise { const pluginResult = await this.pluginTokenHandler.verifyToken(token); if (pluginResult) { + if (pluginResult.limitedUserToken) { + const userResult = await this.userTokenHandler.verifyToken( + pluginResult.limitedUserToken, + ); + if (!userResult) { + throw new AuthenticationError( + 'Invalid user token in plugin token obo claim', + ); + } + return createCredentialsWithUserPrincipal( + userResult.userEntityRef, + pluginResult.limitedUserToken, + this.#getJwtExpiration(pluginResult.limitedUserToken), + ); + } return createCredentialsWithServicePrincipal(pluginResult.subject); } @@ -194,14 +209,15 @@ class DefaultAuthService implements AuthService { return { token: '' }; } + const targetSupportsNewAuth = + await this.pluginTokenHandler.isTargetPluginSupported(targetPluginId); + // check whether a plugin support the new auth system // by checking the public keys endpoint existance. switch (type) { // TODO: Check whether the principal is ourselves case 'service': - if ( - await this.pluginTokenHandler.isTargetPluginSupported(targetPluginId) - ) { + if (targetSupportsNewAuth) { return this.pluginTokenHandler.issueToken({ pluginId: this.pluginId, targetPluginId, @@ -209,11 +225,31 @@ class DefaultAuthService implements AuthService { } // If the target plugin does not support the new auth service, fall back to using old token format return this.tokenManager.getToken(); - case 'user': - if (!internalForward.token) { + case 'user': { + const { token } = internalForward; + if (!token) { throw new Error('User credentials is unexpectedly missing token'); } - return { token: internalForward.token }; + // If the target plugin supports the new auth service we issue a service + // on-behalf-of token rather than forwarding the user token + if (targetSupportsNewAuth) { + const onBehalfOf = await this.userTokenHandler.createLimitedUserToken( + token, + ); + return this.pluginTokenHandler.issueToken({ + pluginId: this.pluginId, + targetPluginId, + onBehalfOf, + }); + } + + if (this.userTokenHandler.isLimitedUserToken(token)) { + throw new AuthenticationError( + `Unable to call '${targetPluginId}' plugin on behalf of user, because the target plugin does not support on-behalf-of tokens`, + ); + } + return { token }; + } default: throw new AuthenticationError( `Refused to issue service token for credential type '${type}'`,