From 4194ac7200ba4ac420f1c5d551bb3bdc2db573b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 27 Mar 2024 23:57:33 +0100 Subject: [PATCH 01/12] auth: issue user identity claims and create limited user tokens from them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Camila Belo Signed-off-by: Fredrik Adelöw --- .../auth/authServiceFactory.test.ts | 4 + .../auth/authServiceFactory.ts | 38 ++++++-- .../src/identity/TokenFactory.test.ts | 57 ++++++++++- .../auth-backend/src/identity/TokenFactory.ts | 96 +++++++++++++++--- plugins/auth-node/api-report.md | 35 +++++++ plugins/auth-node/src/index.ts | 5 +- plugins/auth-node/src/types.ts | 97 +++++++++++++++++++ 7 files changed, 306 insertions(+), 26 deletions(-) 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 4600110b5c..64266db857 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 @@ -127,4 +127,8 @@ describe('authServiceFactory', () => { }), ); }); + + it('should issue limited user tokens', async () => { + // TODO + }); }); 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 ac5c1f9399..f689df7be9 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -28,7 +28,7 @@ import { } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; import { IdentityApiGetIdentityRequest } from '@backstage/plugin-auth-node'; -import { decodeJwt } from 'jose'; +import { base64url, decodeJwt } from 'jose'; /** @internal */ export type InternalBackstageCredentials = @@ -204,17 +204,41 @@ class DefaultAuthService implements AuthService { async getLimitedUserToken( credentials: BackstageCredentials, ): Promise<{ token: string; expiresAt: Date }> { - const internalCredentials = toInternalBackstageCredentials(credentials); - - const { token } = internalCredentials; - - if (!token) { + const { token: backstageToken } = + toInternalBackstageCredentials(credentials); + if (!backstageToken) { throw new AuthenticationError( 'User credentials is unexpectedly missing token', ); } - return { token, expiresAt: this.#getJwtExpiration(token) }; + const [headerRaw, payloadRaw] = backstageToken.split('.'); + const header = JSON.parse( + new TextDecoder().decode(base64url.decode(headerRaw)), + ); + const payload = JSON.parse( + new TextDecoder().decode(base64url.decode(payloadRaw)), + ); + + const limitedUserToken = [ + base64url.encode( + JSON.stringify({ + alg: header.alg, + kid: header.kid, + }), + ), + base64url.encode( + JSON.stringify({ + typ: 'vnd.backstage.limited-user', + sub: payload.sub, + iat: payload.iat, + exp: payload.exp, + }), + ), + payload.uip, + ].join('.'); + + return { token: limitedUserToken, expiresAt: new Date(payload.exp * 1000) }; } #getJwtExpiration(token: string) { diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index e1dd12333d..6e97742a88 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -13,12 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { getVoidLogger } from '@backstage/backend-common'; import { stringifyEntityRef } from '@backstage/catalog-model'; -import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from 'jose'; - +import { + base64url, + createLocalJWKSet, + decodeProtectedHeader, + jwtVerify, +} from 'jose'; import { MemoryKeyStore } from './MemoryKeyStore'; import { TokenFactory } from './TokenFactory'; +import { + BackstageUserIdentityProofPayload, + BackstageTokenPayload, + TokenTypes, +} from '@backstage/plugin-auth-node'; const logger = getVoidLogger(); @@ -59,19 +69,58 @@ describe('TokenFactory', () => { const { keys } = await factory.listPublicKeys(); const keyStore = createLocalJWKSet({ keys: keys }); - const verifyResult = await jwtVerify(token, keyStore); + const verifyResult = await jwtVerify( + token, + keyStore, + ); expect(verifyResult.payload).toEqual({ + typ: TokenTypes.user.typClaim, iss: 'my-issuer', - aud: 'backstage', + aud: TokenTypes.user.audClaim, sub: entityRef, ent: [entityRef], 'x-fancy-claim': 'my special claim', iat: expect.any(Number), exp: expect.any(Number), + uip: expect.any(String), }); expect(verifyResult.payload.exp).toBe( verifyResult.payload.iat! + keyDurationSeconds, ); + + // Emulate the reconstruction of a limited user token + const limitedUserToken = [ + base64url.encode( + JSON.stringify({ + alg: verifyResult.protectedHeader.alg, + kid: verifyResult.protectedHeader.kid!, + }), + ), + base64url.encode( + JSON.stringify({ + typ: TokenTypes.limitedUser.typClaim, + sub: verifyResult.payload.sub, + iat: verifyResult.payload.iat, + exp: verifyResult.payload.exp, + }), + ), + verifyResult.payload.uip, + ].join('.'); + + const verifyProofResult = + await jwtVerify( + limitedUserToken, + keyStore, + ); + expect(verifyProofResult.payload).toEqual({ + typ: TokenTypes.limitedUser.typClaim, + sub: entityRef, + iat: expect.any(Number), + exp: expect.any(Number), + }); + expect(verifyProofResult.payload.exp).toBe( + verifyProofResult.payload.iat! + keyDurationSeconds, + ); }); it('should generate new signing keys when the current one expires', async () => { diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index 4c2e7bac7a..b0cde6ed3b 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -16,11 +16,24 @@ import { parseEntityRef } from '@backstage/catalog-model'; import { AuthenticationError } from '@backstage/errors'; -import { exportJWK, generateKeyPair, importJWK, JWK, SignJWT } from 'jose'; +import { + exportJWK, + generateKeyPair, + importJWK, + JWK, + SignJWT, + GeneralSign, + KeyLike, +} from 'jose'; import { DateTime } from 'luxon'; import { v4 as uuid } from 'uuid'; import { LoggerService } from '@backstage/backend-plugin-api'; -import { TokenParams } from '@backstage/plugin-auth-node'; +import { + BackstageTokenPayload, + BackstageUserIdentityProofPayload, + TokenParams, + TokenTypes, +} from '@backstage/plugin-auth-node'; import { AnyJWK, KeyStore, TokenIssuer } from './types'; const MS_IN_S = 1000; @@ -79,13 +92,13 @@ export class TokenFactory implements TokenIssuer { const key = await this.getKey(); const iss = this.issuer; - const { sub, ent, ...additionalClaims } = params.claims; - const aud = 'backstage'; + const { sub, ent = [sub], ...additionalClaims } = params.claims; + const aud = TokenTypes.user.audClaim; const iat = Math.floor(Date.now() / MS_IN_S); const exp = iat + this.keyDurationSeconds; - // Validate that the subject claim is a valid EntityRef try { + // The subject must be a valid entity ref parseEntityRef(sub); } catch (error) { throw new Error( @@ -93,21 +106,35 @@ export class TokenFactory implements TokenIssuer { ); } - this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`); - if (!key.alg) { throw new AuthenticationError('No algorithm was provided in the key'); } - const claims = { ...additionalClaims, iss, sub, ent, aud, iat, exp }; + this.logger.info(`Issuing token for ${sub}, with entities ${ent}`); + + const signingKey = await importJWK(key); + + const uip = await this.createUserIdentityClaim({ + header: { alg: key.alg, kid: key.kid }, + payload: { typ: TokenTypes.limitedUser.typClaim, sub, iat, exp }, + key: signingKey, + }); + + const claims: BackstageTokenPayload = { + ...additionalClaims, + typ: TokenTypes.user.typClaim, + iss, + sub, + ent, + aud, + iat, + exp, + uip, + }; + const token = await new SignJWT(claims) .setProtectedHeader({ alg: key.alg, kid: key.kid }) - .setIssuer(iss) - .setAudience(aud) - .setSubject(sub) - .setIssuedAt(iat) - .setExpirationTime(exp) - .sign(await importJWK(key)); + .sign(signingKey); if (token.length > MAX_TOKEN_LENGTH) { throw new Error( @@ -210,4 +237,45 @@ export class TokenFactory implements TokenIssuer { return promise; } + + // Creates a string claim that can be used as part of reconstructing a limited + // user token. The output of this function is only the signature part of a + // JWS. + private async createUserIdentityClaim(options: { + header: { + alg: string; + kid?: string; + }; + payload: BackstageUserIdentityProofPayload; + key: KeyLike | Uint8Array; + }): Promise { + // NOTE: We reconstruct the header and payload structures carefully to + // perfectly guarantee ordering. The reason for this is that we store only + // the signature part of these to reduce duplication within the Backstage + // token. Anyone who wants to make an actual JWT based on all this must be + // able to do the EXACT reconstruction of the header and payload parts, to + // then append the signature. + + const header = { + alg: options.header.alg, + ...(options.header.kid ? { kid: options.header.kid } : {}), + }; + + const payload = { + typ: options.payload.typ, + sub: options.payload.sub, + iat: options.payload.iat, + exp: options.payload.exp, + }; + + const jws = await new GeneralSign( + new TextEncoder().encode(JSON.stringify(payload)), + ) + .addSignature(options.key) + .setProtectedHeader(header) + .done() + .sign(); + + return jws.signatures[0].signature; + } } diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index 26e11236d2..ffa0ef94f8 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -109,6 +109,19 @@ export interface BackstageSignInResult { token: string; } +// @public +export interface BackstageTokenPayload { + [claim: string]: JsonValue; + aud: typeof TokenTypes.user.audClaim; + ent: string[]; + exp: number; + iat: number; + iss: string; + sub: string; + typ: typeof TokenTypes.user.typClaim; + uip: string; +} + // @public export type BackstageUserIdentity = { type: 'user'; @@ -116,6 +129,14 @@ export type BackstageUserIdentity = { ownershipEntityRefs: string[]; }; +// @public +export interface BackstageUserIdentityProofPayload { + exp: number; + iat: number; + sub: string; + typ: typeof TokenTypes.limitedUser.typClaim; +} + // @public (undocumented) export type ClientAuthResponse = { providerInfo: TProviderInfo; @@ -639,6 +660,20 @@ export type TokenParams = { } & Record; }; +// @public +export const TokenTypes: Readonly<{ + user: Readonly<{ + typClaim: 'vnd.backstage.user'; + audClaim: 'backstage'; + }>; + limitedUser: Readonly<{ + typClaim: 'vnd.backstage.limited-user'; + }>; + service: Readonly<{ + typClaim: 'vnd.backstage.service'; + }>; +}>; + // @public export type WebMessageResponse = | { diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index e7b0b628b8..447e6dc715 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -29,13 +29,15 @@ export * from './proxy'; export * from './sign-in'; export type { AuthProviderConfig, - AuthProviderRouteHandlers, AuthProviderFactory, + AuthProviderRouteHandlers, AuthResolverCatalogUserQuery, AuthResolverContext, BackstageIdentityResponse, BackstageSignInResult, + BackstageTokenPayload, BackstageUserIdentity, + BackstageUserIdentityProofPayload, ClientAuthResponse, CookieConfigurer, ProfileInfo, @@ -44,3 +46,4 @@ export type { SignInResolver, TokenParams, } from './types'; +export { TokenTypes } from './types'; diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts index 1ee9e9cd96..97bee3e3c6 100644 --- a/plugins/auth-node/src/types.ts +++ b/plugins/auth-node/src/types.ts @@ -375,3 +375,100 @@ export type CookieConfigurer = (ctx: { secure: boolean; sameSite?: 'none' | 'lax' | 'strict'; }; + +/** + * Core properties of various token types. + * + * @public + */ +export const TokenTypes = Object.freeze({ + user: Object.freeze({ + typClaim: 'vnd.backstage.user', + audClaim: 'backstage', + }), + limitedUser: Object.freeze({ + typClaim: 'vnd.backstage.limited-user', + }), + service: Object.freeze({ + typClaim: 'vnd.backstage.service', + }), +}); + +/** + * The payload contents of a valid Backstage JWT token + * + * @public + */ +export interface BackstageTokenPayload { + /** + * The token type + */ + typ: typeof TokenTypes.user.typClaim; + + /** + * The issuer of the token, currently the discovery URL of the auth backend + */ + iss: string; + + /** + * The entity ref of the user + */ + sub: string; + + /** + * The entity refs that the user claims ownership througg + */ + ent: string[]; + + /** + * A hard coded audience string + */ + aud: typeof TokenTypes.user.audClaim; + + /** + * Standard expiry in epoch seconds + */ + exp: number; + + /** + * Standard issue time in epoch seconds + */ + iat: number; + + /** + * A separate user identity proof that the auth service can convert to a limited user token + */ + uip: string; + + /** + * Any other custom claims that the adopter may have added + */ + [claim: string]: JsonValue; +} + +/** + * The payload contents of a valid Backstage user identity claim token + * + * @public + */ +export interface BackstageUserIdentityProofPayload { + /** + * The token type + */ + typ: typeof TokenTypes.limitedUser.typClaim; + + /** + * The entity ref of the user + */ + sub: string; + + /** + * Standard expiry in epoch seconds + */ + exp: number; + + /** + * Standard issue time in epoch seconds + */ + iat: number; +} From 3493c914cf96725ce0991273b5ea25827d8cde1c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 2 Apr 2024 16:05:33 +0200 Subject: [PATCH 02/12] backend-app-api: initial test for issuing limited tokens Co-authored-by: Camila Belo Signed-off-by: Patrik Oldsberg --- .../auth/authServiceFactory.test.ts | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) 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 64266db857..f987f3dea5 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,7 +22,7 @@ import { InternalBackstageCredentials, authServiceFactory, } from './authServiceFactory'; -import { decodeJwt } from 'jose'; +import { base64url, decodeJwt } from 'jose'; import { discoveryServiceFactory } from '../discovery'; import { BackstageServicePrincipal, @@ -129,6 +129,61 @@ describe('authServiceFactory', () => { }); it('should issue limited user tokens', async () => { - // TODO + const publicKey = [ + { + kty: 'EC', + x: 'Xd7ATJLz0085GTqYTKdl3oSZqHwcs-l1bMxrG7iFMOw', + y: 'EvFsODRaJsNWKLgknbHeCE1KxAPZL2WiSNkXB5gO1WM', + crv: 'P-256', + kid: 'b49bc495-e926-4ff9-b44f-4100e2dc069d', + alg: 'ES256', + }, + ]; + + const tester = ServiceFactoryTester.from(authServiceFactory, { + dependencies: mockDeps, + }); + + const catalogAuth = await tester.get('catalog'); + + const fullToken = + 'eyJhbGciOiJFUzI1NiIsImtpZCI6ImI0OWJjNDk1LWU5MjYtNGZmOS1iNDRmLTQxMDBlMmRjMDY5ZCJ9.eyJ0eXAiOiJ2bmQuYmFja3N0YWdlLnVzZXIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJ1c2VyOmRldmVsb3BtZW50L2d1ZXN0IiwiZW50IjpbInVzZXI6ZGV2ZWxvcG1lbnQvZ3Vlc3QiLCJncm91cDpkZWZhdWx0L3RlYW0tYSJdLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE3MTIwNjQ0NzksImV4cCI6MTcxMjA2ODA3OSwidWlwIjoiMVQxR1JIcGpsdF9oRl8zM2trUFZ2QjdqM1dkWlJqcFowVHVnLXJTaXQwRHNQclJLY1V4eGU3VGVpZDhCbDhCTDE2QnRtTTRWTzJzQ0ExcjVkWUdLS2ZnIn0.5fFibx-RJVPHOvJNSCLGbUg3_sJVUMnyfN6QAq5abyKi8wtbDCCUAI9_x0Rb22KYCmBolV_cdjut-V6wQ3YmBg'; + + const credentials = await catalogAuth.authenticate(fullToken); + if (!catalogAuth.isPrincipal(credentials, 'user')) { + throw new Error('no a user principal'); + } + + const { token: limitedToken, expiresAt } = + await catalogAuth.getLimitedUserToken(credentials); + + expect(expiresAt).toEqual(new Date(1712068079 * 1000)); + + const expectedTokenHeader = base64url.encode( + JSON.stringify({ + alg: 'ES256', + kid: 'b49bc495-e926-4ff9-b44f-4100e2dc069d', + }), + ); + const expectedTokenPayload = base64url.encode( + JSON.stringify({ + typ: 'vnd.backstage.limited-user', + sub: 'user:development/guest', + ent: ['user:development/guest', 'group:default/team-a'], + iat: 1712064479, + exp: 1712068079, + }), + ); + const expectedTokenSignature = JSON.parse( + atob(fullToken.split('.')[1]), + ).uip; + + const expectedToken = `${expectedTokenHeader}.${expectedTokenPayload}.${expectedTokenSignature}`; + + expect(limitedToken).toBe(expectedToken); + + const limitedCredentials = await catalogAuth.authenticate(limitedToken, { + allowLimitedAccess: true, + }); }); }); From 3d30d36eab14f4e67f6394c61e1274e785cbbe3e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 2 Apr 2024 16:28:58 +0200 Subject: [PATCH 03/12] backend-app-api: add initial handling of different token typ claims Co-authored-by: Camila Belo Signed-off-by: Patrik Oldsberg --- .../auth/authServiceFactory.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) 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 f689df7be9..6c3a80e101 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -27,7 +27,10 @@ import { createServiceFactory, } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; -import { IdentityApiGetIdentityRequest } from '@backstage/plugin-auth-node'; +import { + IdentityApiGetIdentityRequest, + TokenTypes, +} from '@backstage/plugin-auth-node'; import { base64url, decodeJwt } from 'jose'; /** @internal */ @@ -112,7 +115,21 @@ class DefaultAuthService implements AuthService { // allowLimitedAccess is currently ignored, since we currently always use the full user tokens async authenticate(token: string): Promise { - const { sub, aud } = decodeJwt(token); + const { typ, sub, aud } = decodeJwt(token); + + if (typ) { + switch (typ) { + case TokenTypes.user.typClaim: + return this.#authenticateFullUser(token); + case TokenTypes.limitedUser.typClaim: + throw new AuthenticationError( + 'Limited user tokens are not supported', + ); + + default: + throw new AuthenticationError("Invalid token 'typ' claim"); + } + } // Legacy service-to-service token if (sub === 'backstage-server' && !aud) { @@ -121,6 +138,10 @@ class DefaultAuthService implements AuthService { } // User Backstage token + return this.#authenticateFullUser(token); + } + + async #authenticateFullUser(token: string) { const identity = await this.identity.getIdentity({ request: { headers: { authorization: `Bearer ${token}` }, From 018b0910e06fa697b0bad6d9724357ad91f9958d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 2 Apr 2024 16:29:26 +0200 Subject: [PATCH 04/12] backend-app-api,auth: add ent claim to user identity proof Co-authored-by: Camila Belo Signed-off-by: Patrik Oldsberg --- .../src/services/implementations/auth/authServiceFactory.ts | 1 + plugins/auth-backend/src/identity/TokenFactory.ts | 3 ++- plugins/auth-node/src/types.ts | 5 +++++ 3 files changed, 8 insertions(+), 1 deletion(-) 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 6c3a80e101..39dd88aafa 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -252,6 +252,7 @@ class DefaultAuthService implements AuthService { JSON.stringify({ typ: 'vnd.backstage.limited-user', sub: payload.sub, + ent: payload.ent, iat: payload.iat, exp: payload.exp, }), diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index b0cde6ed3b..b89225eca5 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -116,7 +116,7 @@ export class TokenFactory implements TokenIssuer { const uip = await this.createUserIdentityClaim({ header: { alg: key.alg, kid: key.kid }, - payload: { typ: TokenTypes.limitedUser.typClaim, sub, iat, exp }, + payload: { typ: TokenTypes.limitedUser.typClaim, sub, ent, iat, exp }, key: signingKey, }); @@ -264,6 +264,7 @@ export class TokenFactory implements TokenIssuer { const payload = { typ: options.payload.typ, sub: options.payload.sub, + ent: options.payload.ent, iat: options.payload.iat, exp: options.payload.exp, }; diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts index 97bee3e3c6..fe84da38a4 100644 --- a/plugins/auth-node/src/types.ts +++ b/plugins/auth-node/src/types.ts @@ -462,6 +462,11 @@ export interface BackstageUserIdentityProofPayload { */ sub: string; + /** + * The ownership entity refs of the user + */ + ent?: string[]; + /** * Standard expiry in epoch seconds */ From 0d2a05418b5549bb2c66d0e680058d79dd0c73ff Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 2 Apr 2024 17:40:21 +0200 Subject: [PATCH 05/12] backend-app-api,auth: move token typ claim to be a header param Co-authored-by: Camila Belo Signed-off-by: Patrik Oldsberg --- .../implementations/auth/authServiceFactory.ts | 14 ++++++-------- .../auth-backend/src/identity/TokenFactory.ts | 18 +++++++++++++----- plugins/auth-node/src/types.ts | 16 +++------------- 3 files changed, 22 insertions(+), 26 deletions(-) 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 39dd88aafa..2f75847608 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -22,16 +22,13 @@ import { BackstageServicePrincipal, BackstageNonePrincipal, BackstageUserPrincipal, - IdentityService, coreServices, createServiceFactory, } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; -import { - IdentityApiGetIdentityRequest, - TokenTypes, -} from '@backstage/plugin-auth-node'; -import { base64url, decodeJwt } from 'jose'; +import { TokenTypes } from '@backstage/plugin-auth-node'; +import { base64url, decodeJwt, decodeProtectedHeader } from 'jose'; +import { UserTokenHandler } from './UserTokenHandler'; /** @internal */ export type InternalBackstageCredentials = @@ -115,7 +112,8 @@ class DefaultAuthService implements AuthService { // allowLimitedAccess is currently ignored, since we currently always use the full user tokens async authenticate(token: string): Promise { - const { typ, sub, aud } = decodeJwt(token); + const { typ } = decodeProtectedHeader(token); + const { sub, aud } = decodeJwt(token); if (typ) { switch (typ) { @@ -244,13 +242,13 @@ class DefaultAuthService implements AuthService { const limitedUserToken = [ base64url.encode( JSON.stringify({ + typ: 'vnd.backstage.limited-user', alg: header.alg, kid: header.kid, }), ), base64url.encode( JSON.stringify({ - typ: 'vnd.backstage.limited-user', sub: payload.sub, ent: payload.ent, iat: payload.iat, diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index b89225eca5..1e440a5695 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -115,14 +115,17 @@ export class TokenFactory implements TokenIssuer { const signingKey = await importJWK(key); const uip = await this.createUserIdentityClaim({ - header: { alg: key.alg, kid: key.kid }, - payload: { typ: TokenTypes.limitedUser.typClaim, sub, ent, iat, exp }, + header: { + typ: TokenTypes.limitedUser.typParam, + alg: key.alg, + kid: key.kid, + }, + payload: { sub, ent, iat, exp }, key: signingKey, }); const claims: BackstageTokenPayload = { ...additionalClaims, - typ: TokenTypes.user.typClaim, iss, sub, ent, @@ -133,7 +136,11 @@ export class TokenFactory implements TokenIssuer { }; const token = await new SignJWT(claims) - .setProtectedHeader({ alg: key.alg, kid: key.kid }) + .setProtectedHeader({ + typ: TokenTypes.user.typParam, + alg: key.alg, + kid: key.kid, + }) .sign(signingKey); if (token.length > MAX_TOKEN_LENGTH) { @@ -243,6 +250,7 @@ export class TokenFactory implements TokenIssuer { // JWS. private async createUserIdentityClaim(options: { header: { + typ: string; alg: string; kid?: string; }; @@ -257,12 +265,12 @@ export class TokenFactory implements TokenIssuer { // then append the signature. const header = { + typ: options.header.typ, alg: options.header.alg, ...(options.header.kid ? { kid: options.header.kid } : {}), }; const payload = { - typ: options.payload.typ, sub: options.payload.sub, ent: options.payload.ent, iat: options.payload.iat, diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts index fe84da38a4..8fdb91845d 100644 --- a/plugins/auth-node/src/types.ts +++ b/plugins/auth-node/src/types.ts @@ -383,14 +383,14 @@ export type CookieConfigurer = (ctx: { */ export const TokenTypes = Object.freeze({ user: Object.freeze({ - typClaim: 'vnd.backstage.user', + typParam: 'vnd.backstage.user', audClaim: 'backstage', }), limitedUser: Object.freeze({ - typClaim: 'vnd.backstage.limited-user', + typParam: 'vnd.backstage.limited-user', }), service: Object.freeze({ - typClaim: 'vnd.backstage.service', + typParam: 'vnd.backstage.service', }), }); @@ -400,11 +400,6 @@ export const TokenTypes = Object.freeze({ * @public */ export interface BackstageTokenPayload { - /** - * The token type - */ - typ: typeof TokenTypes.user.typClaim; - /** * The issuer of the token, currently the discovery URL of the auth backend */ @@ -452,11 +447,6 @@ export interface BackstageTokenPayload { * @public */ export interface BackstageUserIdentityProofPayload { - /** - * The token type - */ - typ: typeof TokenTypes.limitedUser.typClaim; - /** * The entity ref of the user */ From 7396a75d626e51648100b00bc77029eaa02e4b6a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 2 Apr 2024 17:40:45 +0200 Subject: [PATCH 06/12] backend-app-api: complete limited token implementation and verification + test Co-authored-by: Camila Belo Signed-off-by: Patrik Oldsberg --- packages/backend-app-api/package.json | 1 + .../implementations/auth/UserTokenHandler.ts | 137 ++++++++++++++++++ .../auth/authServiceFactory.test.ts | 70 +++++++-- .../auth/authServiceFactory.ts | 50 +++---- yarn.lock | 1 + 5 files changed, 218 insertions(+), 41 deletions(-) create mode 100644 packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index d49cbf3774..2339a1a27b 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -90,6 +90,7 @@ "@types/node-forge": "^1.3.0", "@types/stoppable": "^1.1.0", "http-errors": "^2.0.0", + "msw": "^1.0.0", "supertest": "^6.1.3" }, "configSchema": "config.d.ts", diff --git a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts new file mode 100644 index 0000000000..8a7debbe17 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts @@ -0,0 +1,137 @@ +/* + * Copyright 2020 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 { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; +import { AuthenticationError } from '@backstage/errors'; +import { TokenTypes } from '@backstage/plugin-auth-node'; +import { + createRemoteJWKSet, + decodeJwt, + decodeProtectedHeader, + FlattenedJWSInput, + JWSHeaderParameters, + jwtVerify, +} from 'jose'; +import { GetKeyFunction } from 'jose/dist/types/types'; + +const CLOCK_MARGIN_S = 10; + +/** + * An identity client to interact with auth-backend and authenticate Backstage + * tokens + * + * @internal + */ +export class UserTokenHandler { + readonly #discovery: PluginEndpointDiscovery; + readonly #algorithms?: string[]; + + #keyStore?: GetKeyFunction; + #keyStoreUpdated: number = 0; + + constructor(options: { discovery: DiscoveryService }) { + this.#discovery = options.discovery; + this.#algorithms = ['ES256']; // TODO: configurable? + } + + async verifyFullUserToken(token: string): Promise<{ userEntityRef: string }> { + // Check if the keystore needs to be updated + await this.refreshKeyStore(token); + if (!this.#keyStore) { + throw new AuthenticationError('No keystore exists'); + } + + // Verify token claims and signature + // Note: Claims must match those set by TokenFactory when issuing tokens + // Note: verify throws if verification fails + const { payload } = await jwtVerify(token, this.#keyStore, { + algorithms: this.#algorithms, + audience: 'backstage', + }).catch(e => { + console.log(`DEBUG: e=`, e); + throw new AuthenticationError('invalid token', e); + }); + + const userEntityRef = payload.sub; + + if (!userEntityRef) { + throw new AuthenticationError('No user sub found in token'); + } + + return { userEntityRef }; + } + + async verifyLimitedUserToken( + token: string, + ): Promise<{ userEntityRef: string }> { + await this.refreshKeyStore(token); + if (!this.#keyStore) { + throw new AuthenticationError('No keystore exists'); + } + + // Verify a limited token, ensuring the necessarily claims are present and token type is correct + const { payload } = await jwtVerify(token, this.#keyStore, { + algorithms: this.#algorithms, + requiredClaims: ['iat', 'exp', 'sub'], + typ: TokenTypes.limitedUser.typParam, + }).catch(e => { + console.log(`DEBUG: e=`, e); + throw new AuthenticationError('invalid token', e); + }); + + const userEntityRef = payload.sub; + + if (!userEntityRef) { + throw new AuthenticationError('No user sub found in token'); + } + + return { userEntityRef }; + } + + /** + * If the last keystore refresh is stale, update the keystore URL to the latest + */ + private async refreshKeyStore(rawJwtToken: string): Promise { + const payload = await decodeJwt(rawJwtToken); + const header = await decodeProtectedHeader(rawJwtToken); + + // Refresh public keys if needed + let keyStoreHasKey; + try { + if (this.#keyStore) { + // Check if the key is present in the keystore + const [_, rawPayload, rawSignature] = rawJwtToken.split('.'); + keyStoreHasKey = await this.#keyStore(header, { + payload: rawPayload, + signature: rawSignature, + }); + } + } catch (error) { + keyStoreHasKey = false; + } + // Refresh public key URL if needed + // Add a small margin in case clocks are out of sync + const issuedAfterLastRefresh = + payload?.iat && payload.iat > this.#keyStoreUpdated - CLOCK_MARGIN_S; + if (!this.#keyStore || (!keyStoreHasKey && issuedAfterLastRefresh)) { + const url = await this.#discovery.getBaseUrl('auth'); + const endpoint = new URL(`${url}/.well-known/jwks.json`); + this.#keyStore = createRemoteJWKSet(endpoint); + this.#keyStoreUpdated = Date.now() / 1000; + } + } +} 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 f987f3dea5..07193909ab 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 @@ -17,6 +17,7 @@ import { ServiceFactoryTester, mockServices, + setupRequestMockHandlers, } from '@backstage/backend-test-utils'; import { InternalBackstageCredentials, @@ -29,6 +30,10 @@ import { BackstageUserPrincipal, } from '@backstage/backend-plugin-api'; import { tokenManagerServiceFactory } from '../tokenManager'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + +const server = setupServer(); // TODO: Ship discovery mock service in the service factory tester const mockDeps = [ @@ -45,6 +50,12 @@ const mockDeps = [ ]; describe('authServiceFactory', () => { + setupRequestMockHandlers(server); + + afterEach(() => { + jest.useRealTimers(); + }); + it('should authenticate issued tokens', async () => { const tester = ServiceFactoryTester.from(authServiceFactory, { dependencies: mockDeps, @@ -129,16 +140,33 @@ describe('authServiceFactory', () => { }); it('should issue limited user tokens', async () => { - const publicKey = [ - { - kty: 'EC', - x: 'Xd7ATJLz0085GTqYTKdl3oSZqHwcs-l1bMxrG7iFMOw', - y: 'EvFsODRaJsNWKLgknbHeCE1KxAPZL2WiSNkXB5gO1WM', - crv: 'P-256', - kid: 'b49bc495-e926-4ff9-b44f-4100e2dc069d', - alg: 'ES256', - }, - ]; + 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', + }, + ], + }), + ), + ), + ); + + const expectedIssuedAt = 1712071714; + const expectedExpiresAt = 1712075314; + + jest.useFakeTimers({ + now: expectedIssuedAt * 1000 + 600_000, + }); const tester = ServiceFactoryTester.from(authServiceFactory, { dependencies: mockDeps, @@ -147,7 +175,7 @@ describe('authServiceFactory', () => { const catalogAuth = await tester.get('catalog'); const fullToken = - 'eyJhbGciOiJFUzI1NiIsImtpZCI6ImI0OWJjNDk1LWU5MjYtNGZmOS1iNDRmLTQxMDBlMmRjMDY5ZCJ9.eyJ0eXAiOiJ2bmQuYmFja3N0YWdlLnVzZXIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJ1c2VyOmRldmVsb3BtZW50L2d1ZXN0IiwiZW50IjpbInVzZXI6ZGV2ZWxvcG1lbnQvZ3Vlc3QiLCJncm91cDpkZWZhdWx0L3RlYW0tYSJdLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE3MTIwNjQ0NzksImV4cCI6MTcxMjA2ODA3OSwidWlwIjoiMVQxR1JIcGpsdF9oRl8zM2trUFZ2QjdqM1dkWlJqcFowVHVnLXJTaXQwRHNQclJLY1V4eGU3VGVpZDhCbDhCTDE2QnRtTTRWTzJzQ0ExcjVkWUdLS2ZnIn0.5fFibx-RJVPHOvJNSCLGbUg3_sJVUMnyfN6QAq5abyKi8wtbDCCUAI9_x0Rb22KYCmBolV_cdjut-V6wQ3YmBg'; + 'eyJ0eXAiOiJ2bmQuYmFja3N0YWdlLnVzZXIiLCJhbGciOiJFUzI1NiIsImtpZCI6IjhkMDFjM2RiLTU2ZjktNDVmMC04NmRkLTA1YjNjODM1YjNkMyJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJ1c2VyOmRldmVsb3BtZW50L2d1ZXN0IiwiZW50IjpbInVzZXI6ZGV2ZWxvcG1lbnQvZ3Vlc3QiLCJncm91cDpkZWZhdWx0L3RlYW0tYSJdLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE3MTIwNzE3MTQsImV4cCI6MTcxMjA3NTMxNCwidWlwIjoiMDFBUUJfSWpHTXRWc2gyWmgzZEg1NXhOX29pSVlhQ1F3ODJjeDZ5M1BQMXlpTjM4eGMzMVpMS2U0YVNDQlJTTy10cjFzZFUzT29ELUxJYV8tNV9RVUEifQ.mjIrZGqbZ2t68fS4U3crlGw-bYJZnMlhMHf-YL7q_u1HfaLr4NMTcHkxdnNS2wfJxCmUBxRfUS8b3nSAKsxcHA'; const credentials = await catalogAuth.authenticate(fullToken); if (!catalogAuth.isPrincipal(credentials, 'user')) { @@ -157,21 +185,21 @@ describe('authServiceFactory', () => { const { token: limitedToken, expiresAt } = await catalogAuth.getLimitedUserToken(credentials); - expect(expiresAt).toEqual(new Date(1712068079 * 1000)); + expect(expiresAt).toEqual(new Date(expectedExpiresAt * 1000)); const expectedTokenHeader = base64url.encode( JSON.stringify({ + typ: 'vnd.backstage.limited-user', alg: 'ES256', - kid: 'b49bc495-e926-4ff9-b44f-4100e2dc069d', + kid: '8d01c3db-56f9-45f0-86dd-05b3c835b3d3', }), ); const expectedTokenPayload = base64url.encode( JSON.stringify({ - typ: 'vnd.backstage.limited-user', sub: 'user:development/guest', ent: ['user:development/guest', 'group:default/team-a'], - iat: 1712064479, - exp: 1712068079, + iat: expectedIssuedAt, + exp: expectedExpiresAt, }), ); const expectedTokenSignature = JSON.parse( @@ -185,5 +213,15 @@ describe('authServiceFactory', () => { const limitedCredentials = await catalogAuth.authenticate(limitedToken, { allowLimitedAccess: true, }); + + if (!catalogAuth.isPrincipal(limitedCredentials, 'user')) { + throw new Error('Not user credentials'); + } + expect(limitedCredentials.principal.userEntityRef).toBe( + 'user:development/guest', + ); + expect(limitedCredentials.expiresAt).toEqual( + new Date(expectedExpiresAt * 1000), + ); }); }); 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 2f75847608..81172f360b 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -105,7 +105,7 @@ export function toInternalBackstageCredentials( class DefaultAuthService implements AuthService { constructor( private readonly tokenManager: TokenManager, - private readonly identity: IdentityService, + private readonly userTokenHandler: UserTokenHandler, private readonly pluginId: string, private readonly disableDefaultAuthPolicy: boolean, ) {} @@ -117,13 +117,24 @@ class DefaultAuthService implements AuthService { if (typ) { switch (typ) { - case TokenTypes.user.typClaim: - return this.#authenticateFullUser(token); - case TokenTypes.limitedUser.typClaim: - throw new AuthenticationError( - 'Limited user tokens are not supported', + case TokenTypes.user.typParam: { + const { userEntityRef } = + await this.userTokenHandler.verifyFullUserToken(token); + return createCredentialsWithUserPrincipal( + userEntityRef, + token, + this.#getJwtExpiration(token), ); - + } + case TokenTypes.limitedUser.typParam: { + const { userEntityRef } = + await this.userTokenHandler.verifyLimitedUserToken(token); + return createCredentialsWithUserPrincipal( + userEntityRef, + token, + this.#getJwtExpiration(token), + ); + } default: throw new AuthenticationError("Invalid token 'typ' claim"); } @@ -136,22 +147,11 @@ class DefaultAuthService implements AuthService { } // User Backstage token - return this.#authenticateFullUser(token); - } - - async #authenticateFullUser(token: string) { - const identity = await this.identity.getIdentity({ - request: { - headers: { authorization: `Bearer ${token}` }, - }, - } as IdentityApiGetIdentityRequest); - - if (!identity) { - throw new AuthenticationError('Invalid user token'); - } - + const { userEntityRef } = await this.userTokenHandler.verifyFullUserToken( + token, + ); return createCredentialsWithUserPrincipal( - identity.identity.userEntityRef, + userEntityRef, token, this.#getJwtExpiration(token), ); @@ -276,15 +276,15 @@ export const authServiceFactory = createServiceFactory({ deps: { config: coreServices.rootConfig, logger: coreServices.rootLogger, + discovery: coreServices.discovery, plugin: coreServices.pluginMetadata, - identity: coreServices.identity, // Re-using the token manager makes sure that we use the same generated keys for // development as plugins that have not yet been migrated. It's important that this // keeps working as long as there are plugins that have not been migrated to the // new auth services in the new backend system. tokenManager: coreServices.tokenManager, }, - async factory({ config, plugin, identity, tokenManager }) { + async factory({ config, discovery, plugin, tokenManager }) { const disableDefaultAuthPolicy = Boolean( config.getOptionalBoolean( 'backend.auth.dangerouslyDisableDefaultAuthPolicy', @@ -292,7 +292,7 @@ export const authServiceFactory = createServiceFactory({ ); return new DefaultAuthService( tokenManager, - identity, + new UserTokenHandler({ discovery }), plugin.getId(), disableDefaultAuthPolicy, ); diff --git a/yarn.lock b/yarn.lock index 94e38f68ed..563f0dda89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3305,6 +3305,7 @@ __metadata: minimatch: ^9.0.0 minimist: ^1.2.5 morgan: ^1.10.0 + msw: ^1.0.0 node-forge: ^1.3.1 path-to-regexp: ^6.2.1 selfsigned: ^2.0.0 From c2aa1ffe1ccd043e4e681489134762f7be4511ac Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 2 Apr 2024 17:43:35 +0200 Subject: [PATCH 07/12] backend-app-api: move limited token creation to UserTokenHandler Co-authored-by: Camila Belo Signed-off-by: Patrik Oldsberg --- .../implementations/auth/UserTokenHandler.ts | 32 +++++++++++++++++++ .../auth/authServiceFactory.ts | 31 ++---------------- 2 files changed, 34 insertions(+), 29 deletions(-) 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 8a7debbe17..dbada2f1a7 100644 --- a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts @@ -19,6 +19,7 @@ import { DiscoveryService } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; import { TokenTypes } from '@backstage/plugin-auth-node'; import { + base64url, createRemoteJWKSet, decodeJwt, decodeProtectedHeader, @@ -102,6 +103,37 @@ export class UserTokenHandler { return { userEntityRef }; } + createLimitedUserToken(backstageToken: string) { + const [headerRaw, payloadRaw] = backstageToken.split('.'); + const header = JSON.parse( + new TextDecoder().decode(base64url.decode(headerRaw)), + ); + const payload = JSON.parse( + new TextDecoder().decode(base64url.decode(payloadRaw)), + ); + + const limitedUserToken = [ + base64url.encode( + JSON.stringify({ + typ: 'vnd.backstage.limited-user', + alg: header.alg, + kid: header.kid, + }), + ), + base64url.encode( + JSON.stringify({ + sub: payload.sub, + ent: payload.ent, + iat: payload.iat, + exp: payload.exp, + }), + ), + payload.uip, + ].join('.'); + + return { token: limitedUserToken, expiresAt: new Date(payload.exp * 1000) }; + } + /** * If the last keystore refresh is stale, update the keystore URL to the latest */ 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 81172f360b..41b8a12a3a 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -27,7 +27,7 @@ import { } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; import { TokenTypes } from '@backstage/plugin-auth-node'; -import { base64url, decodeJwt, decodeProtectedHeader } from 'jose'; +import { decodeJwt, decodeProtectedHeader } from 'jose'; import { UserTokenHandler } from './UserTokenHandler'; /** @internal */ @@ -231,34 +231,7 @@ class DefaultAuthService implements AuthService { ); } - const [headerRaw, payloadRaw] = backstageToken.split('.'); - const header = JSON.parse( - new TextDecoder().decode(base64url.decode(headerRaw)), - ); - const payload = JSON.parse( - new TextDecoder().decode(base64url.decode(payloadRaw)), - ); - - const limitedUserToken = [ - base64url.encode( - JSON.stringify({ - typ: 'vnd.backstage.limited-user', - alg: header.alg, - kid: header.kid, - }), - ), - base64url.encode( - JSON.stringify({ - sub: payload.sub, - ent: payload.ent, - iat: payload.iat, - exp: payload.exp, - }), - ), - payload.uip, - ].join('.'); - - return { token: limitedUserToken, expiresAt: new Date(payload.exp * 1000) }; + return this.userTokenHandler.createLimitedUserToken(backstageToken); } #getJwtExpiration(token: string) { From 22c0bd2adfef3b598ec88d3733daa1cc116ae4c3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 2 Apr 2024 18:01:43 +0200 Subject: [PATCH 08/12] backend-app-api: refactor auth service implementation to use UserTokenHandler more Co-authored-by: Camila Belo Signed-off-by: Patrik Oldsberg --- .../implementations/auth/UserTokenHandler.ts | 64 +++++++++---------- .../auth/authServiceFactory.ts | 48 ++++---------- 2 files changed, 43 insertions(+), 69 deletions(-) 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 dbada2f1a7..27dc3d1200 100644 --- a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts @@ -26,6 +26,7 @@ import { FlattenedJWSInput, JWSHeaderParameters, jwtVerify, + JWTVerifyOptions, } from 'jose'; import { GetKeyFunction } from 'jose/dist/types/types'; @@ -49,21 +50,23 @@ export class UserTokenHandler { this.#algorithms = ['ES256']; // TODO: configurable? } - async verifyFullUserToken(token: string): Promise<{ userEntityRef: string }> { - // Check if the keystore needs to be updated + async verifyToken(token: string) { + const verifyOpts = this.#getTokenVerificationOptions(token); + if (!verifyOpts) { + return undefined; + } + await this.refreshKeyStore(token); if (!this.#keyStore) { throw new AuthenticationError('No keystore exists'); } - // Verify token claims and signature - // Note: Claims must match those set by TokenFactory when issuing tokens - // Note: verify throws if verification fails - const { payload } = await jwtVerify(token, this.#keyStore, { - algorithms: this.#algorithms, - audience: 'backstage', - }).catch(e => { - console.log(`DEBUG: e=`, e); + // Verify a limited token, ensuring the necessarily claims are present and token type is correct + const { payload } = await jwtVerify( + token, + this.#keyStore, + verifyOpts, + ).catch(e => { throw new AuthenticationError('invalid token', e); }); @@ -76,31 +79,28 @@ export class UserTokenHandler { return { userEntityRef }; } - async verifyLimitedUserToken( - token: string, - ): Promise<{ userEntityRef: string }> { - await this.refreshKeyStore(token); - if (!this.#keyStore) { - throw new AuthenticationError('No keystore exists'); + #getTokenVerificationOptions(token: string): JWTVerifyOptions | undefined { + const { typ } = decodeProtectedHeader(token); + + if (typ === TokenTypes.user.typParam) { + return { + algorithms: this.#algorithms, + typ: TokenTypes.user.typParam, + }; } - // Verify a limited token, ensuring the necessarily claims are present and token type is correct - const { payload } = await jwtVerify(token, this.#keyStore, { + if (typ === TokenTypes.limitedUser.typParam) { + return { + algorithms: this.#algorithms, + requiredClaims: ['iat', 'exp', 'sub'], + typ: TokenTypes.limitedUser.typParam, + }; + } + + return { algorithms: this.#algorithms, - requiredClaims: ['iat', 'exp', 'sub'], - typ: TokenTypes.limitedUser.typParam, - }).catch(e => { - console.log(`DEBUG: e=`, e); - throw new AuthenticationError('invalid token', e); - }); - - const userEntityRef = payload.sub; - - if (!userEntityRef) { - throw new AuthenticationError('No user sub found in token'); - } - - return { userEntityRef }; + audience: 'backstage', + }; } createLimitedUserToken(backstageToken: string) { 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 41b8a12a3a..d39c57d328 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -26,8 +26,7 @@ import { createServiceFactory, } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; -import { TokenTypes } from '@backstage/plugin-auth-node'; -import { decodeJwt, decodeProtectedHeader } from 'jose'; +import { decodeJwt } from 'jose'; import { UserTokenHandler } from './UserTokenHandler'; /** @internal */ @@ -112,49 +111,24 @@ class DefaultAuthService implements AuthService { // allowLimitedAccess is currently ignored, since we currently always use the full user tokens async authenticate(token: string): Promise { - const { typ } = decodeProtectedHeader(token); const { sub, aud } = decodeJwt(token); - if (typ) { - switch (typ) { - case TokenTypes.user.typParam: { - const { userEntityRef } = - await this.userTokenHandler.verifyFullUserToken(token); - return createCredentialsWithUserPrincipal( - userEntityRef, - token, - this.#getJwtExpiration(token), - ); - } - case TokenTypes.limitedUser.typParam: { - const { userEntityRef } = - await this.userTokenHandler.verifyLimitedUserToken(token); - return createCredentialsWithUserPrincipal( - userEntityRef, - token, - this.#getJwtExpiration(token), - ); - } - default: - throw new AuthenticationError("Invalid token 'typ' claim"); - } - } - // Legacy service-to-service token if (sub === 'backstage-server' && !aud) { await this.tokenManager.authenticate(token); return createCredentialsWithServicePrincipal('external:backstage-plugin'); } - // User Backstage token - const { userEntityRef } = await this.userTokenHandler.verifyFullUserToken( - token, - ); - return createCredentialsWithUserPrincipal( - userEntityRef, - token, - this.#getJwtExpiration(token), - ); + const userResult = await this.userTokenHandler.verifyToken(token); + if (userResult) { + return createCredentialsWithUserPrincipal( + userResult.userEntityRef, + token, + this.#getJwtExpiration(token), + ); + } + + throw new AuthenticationError('Unknown token'); } isPrincipal( From ff681360ccaed289838f2cf6acc78c4e63509110 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 3 Apr 2024 13:35:56 +0200 Subject: [PATCH 09/12] refactor: make token types internal Co-authored-by: Patrik Oldsberg Signed-off-by: Camila Belo --- .../implementations/auth/UserTokenHandler.ts | 23 ++--- .../src/identity/TokenFactory.test.ts | 29 +++--- .../auth-backend/src/identity/TokenFactory.ts | 88 +++++++++++++++++-- plugins/auth-node/api-report.md | 29 +----- plugins/auth-node/src/index.ts | 4 +- plugins/auth-node/src/types.ts | 76 +--------------- 6 files changed, 109 insertions(+), 140 deletions(-) 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 27dc3d1200..078b46d3be 100644 --- a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts @@ -17,7 +17,7 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { DiscoveryService } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; -import { TokenTypes } from '@backstage/plugin-auth-node'; +import { tokenTypes } from '@backstage/plugin-auth-node'; import { base64url, createRemoteJWKSet, @@ -82,18 +82,19 @@ export class UserTokenHandler { #getTokenVerificationOptions(token: string): JWTVerifyOptions | undefined { const { typ } = decodeProtectedHeader(token); - if (typ === TokenTypes.user.typParam) { - return { - algorithms: this.#algorithms, - typ: TokenTypes.user.typParam, - }; - } - - if (typ === TokenTypes.limitedUser.typParam) { + if (typ === tokenTypes.user.typParam) { return { algorithms: this.#algorithms, requiredClaims: ['iat', 'exp', 'sub'], - typ: TokenTypes.limitedUser.typParam, + typ: tokenTypes.user.typParam, + }; + } + + if (typ === tokenTypes.limitedUser.typParam) { + return { + algorithms: this.#algorithms, + requiredClaims: ['iat', 'exp', 'sub'], + typ: tokenTypes.limitedUser.typParam, }; } @@ -112,6 +113,8 @@ export class UserTokenHandler { new TextDecoder().decode(base64url.decode(payloadRaw)), ); + // NOTE: The order and properties in both the header and payload must match + // the usage in plugins/auth-backend/src/identity/TokenFactory.ts const limitedUserToken = [ base64url.encode( JSON.stringify({ diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index 6e97742a88..0994e44f68 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -24,11 +24,7 @@ import { } from 'jose'; import { MemoryKeyStore } from './MemoryKeyStore'; import { TokenFactory } from './TokenFactory'; -import { - BackstageUserIdentityProofPayload, - BackstageTokenPayload, - TokenTypes, -} from '@backstage/plugin-auth-node'; +import { tokenTypes } from '@backstage/plugin-auth-node'; const logger = getVoidLogger(); @@ -69,14 +65,11 @@ describe('TokenFactory', () => { const { keys } = await factory.listPublicKeys(); const keyStore = createLocalJWKSet({ keys: keys }); - const verifyResult = await jwtVerify( - token, - keyStore, - ); + const verifyResult = await jwtVerify(token, keyStore); + expect(verifyResult.protectedHeader.typ).toBe(tokenTypes.user.typParam); expect(verifyResult.payload).toEqual({ - typ: TokenTypes.user.typClaim, iss: 'my-issuer', - aud: TokenTypes.user.audClaim, + aud: tokenTypes.user.audClaim, sub: entityRef, ent: [entityRef], 'x-fancy-claim': 'my special claim', @@ -92,14 +85,15 @@ describe('TokenFactory', () => { const limitedUserToken = [ base64url.encode( JSON.stringify({ + typ: tokenTypes.limitedUser.typParam, alg: verifyResult.protectedHeader.alg, kid: verifyResult.protectedHeader.kid!, }), ), base64url.encode( JSON.stringify({ - typ: TokenTypes.limitedUser.typClaim, sub: verifyResult.payload.sub, + ent: verifyResult.payload.ent, iat: verifyResult.payload.iat, exp: verifyResult.payload.exp, }), @@ -107,14 +101,13 @@ describe('TokenFactory', () => { verifyResult.payload.uip, ].join('.'); - const verifyProofResult = - await jwtVerify( - limitedUserToken, - keyStore, - ); + const verifyProofResult = await jwtVerify(limitedUserToken, keyStore); + expect(verifyProofResult.protectedHeader.typ).toBe( + tokenTypes.limitedUser.typParam, + ); expect(verifyProofResult.payload).toEqual({ - typ: TokenTypes.limitedUser.typClaim, sub: entityRef, + ent: [entityRef], iat: expect.any(Number), exp: expect.any(Number), }); diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index 1e440a5695..9277361679 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -28,17 +28,87 @@ import { import { DateTime } from 'luxon'; import { v4 as uuid } from 'uuid'; import { LoggerService } from '@backstage/backend-plugin-api'; -import { - BackstageTokenPayload, - BackstageUserIdentityProofPayload, - TokenParams, - TokenTypes, -} from '@backstage/plugin-auth-node'; +import { TokenParams, tokenTypes } from '@backstage/plugin-auth-node'; import { AnyJWK, KeyStore, TokenIssuer } from './types'; +import { JsonValue } from '@backstage/types'; const MS_IN_S = 1000; const MAX_TOKEN_LENGTH = 32768; // At 64 bytes per entity ref this still leaves room for about 500 entities +/** + * The payload contents of a valid Backstage JWT token + * + * @internal + */ +interface BackstageTokenPayload { + /** + * The issuer of the token, currently the discovery URL of the auth backend + */ + iss: string; + + /** + * The entity ref of the user + */ + sub: string; + + /** + * The entity refs that the user claims ownership througg + */ + ent: string[]; + + /** + * A hard coded audience string + */ + aud: typeof tokenTypes.user.audClaim; + + /** + * Standard expiry in epoch seconds + */ + exp: number; + + /** + * Standard issue time in epoch seconds + */ + iat: number; + + /** + * A separate user identity proof that the auth service can convert to a limited user token + */ + uip: string; + + /** + * Any other custom claims that the adopter may have added + */ + [claim: string]: JsonValue; +} + +/** + * The payload contents of a valid Backstage user identity claim token + * + * @internal + */ +interface BackstageUserIdentityProofPayload { + /** + * The entity ref of the user + */ + sub: string; + + /** + * The ownership entity refs of the user + */ + ent?: string[]; + + /** + * Standard expiry in epoch seconds + */ + exp: number; + + /** + * Standard issue time in epoch seconds + */ + iat: number; +} + type Options = { logger: LoggerService; /** Value of the issuer claim in issued tokens */ @@ -93,7 +163,7 @@ export class TokenFactory implements TokenIssuer { const iss = this.issuer; const { sub, ent = [sub], ...additionalClaims } = params.claims; - const aud = TokenTypes.user.audClaim; + const aud = tokenTypes.user.audClaim; const iat = Math.floor(Date.now() / MS_IN_S); const exp = iat + this.keyDurationSeconds; @@ -116,7 +186,7 @@ export class TokenFactory implements TokenIssuer { const uip = await this.createUserIdentityClaim({ header: { - typ: TokenTypes.limitedUser.typParam, + typ: tokenTypes.limitedUser.typParam, alg: key.alg, kid: key.kid, }, @@ -137,7 +207,7 @@ export class TokenFactory implements TokenIssuer { const token = await new SignJWT(claims) .setProtectedHeader({ - typ: TokenTypes.user.typParam, + typ: tokenTypes.user.typParam, alg: key.alg, kid: key.kid, }) diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index ffa0ef94f8..f9d16c4c3a 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -109,19 +109,6 @@ export interface BackstageSignInResult { token: string; } -// @public -export interface BackstageTokenPayload { - [claim: string]: JsonValue; - aud: typeof TokenTypes.user.audClaim; - ent: string[]; - exp: number; - iat: number; - iss: string; - sub: string; - typ: typeof TokenTypes.user.typClaim; - uip: string; -} - // @public export type BackstageUserIdentity = { type: 'user'; @@ -129,14 +116,6 @@ export type BackstageUserIdentity = { ownershipEntityRefs: string[]; }; -// @public -export interface BackstageUserIdentityProofPayload { - exp: number; - iat: number; - sub: string; - typ: typeof TokenTypes.limitedUser.typClaim; -} - // @public (undocumented) export type ClientAuthResponse = { providerInfo: TProviderInfo; @@ -661,16 +640,16 @@ export type TokenParams = { }; // @public -export const TokenTypes: Readonly<{ +export const tokenTypes: Readonly<{ user: Readonly<{ - typClaim: 'vnd.backstage.user'; + typParam: 'vnd.backstage.user'; audClaim: 'backstage'; }>; limitedUser: Readonly<{ - typClaim: 'vnd.backstage.limited-user'; + typParam: 'vnd.backstage.limited-user'; }>; service: Readonly<{ - typClaim: 'vnd.backstage.service'; + typParam: 'vnd.backstage.service'; }>; }>; diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index 447e6dc715..8b35aaa1e1 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -35,9 +35,7 @@ export type { AuthResolverContext, BackstageIdentityResponse, BackstageSignInResult, - BackstageTokenPayload, BackstageUserIdentity, - BackstageUserIdentityProofPayload, ClientAuthResponse, CookieConfigurer, ProfileInfo, @@ -46,4 +44,4 @@ export type { SignInResolver, TokenParams, } from './types'; -export { TokenTypes } from './types'; +export { tokenTypes } from './types'; diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts index 8fdb91845d..8cc48aacc4 100644 --- a/plugins/auth-node/src/types.ts +++ b/plugins/auth-node/src/types.ts @@ -381,7 +381,7 @@ export type CookieConfigurer = (ctx: { * * @public */ -export const TokenTypes = Object.freeze({ +export const tokenTypes = Object.freeze({ user: Object.freeze({ typParam: 'vnd.backstage.user', audClaim: 'backstage', @@ -393,77 +393,3 @@ export const TokenTypes = Object.freeze({ typParam: 'vnd.backstage.service', }), }); - -/** - * The payload contents of a valid Backstage JWT token - * - * @public - */ -export interface BackstageTokenPayload { - /** - * The issuer of the token, currently the discovery URL of the auth backend - */ - iss: string; - - /** - * The entity ref of the user - */ - sub: string; - - /** - * The entity refs that the user claims ownership througg - */ - ent: string[]; - - /** - * A hard coded audience string - */ - aud: typeof TokenTypes.user.audClaim; - - /** - * Standard expiry in epoch seconds - */ - exp: number; - - /** - * Standard issue time in epoch seconds - */ - iat: number; - - /** - * A separate user identity proof that the auth service can convert to a limited user token - */ - uip: string; - - /** - * Any other custom claims that the adopter may have added - */ - [claim: string]: JsonValue; -} - -/** - * The payload contents of a valid Backstage user identity claim token - * - * @public - */ -export interface BackstageUserIdentityProofPayload { - /** - * The entity ref of the user - */ - sub: string; - - /** - * The ownership entity refs of the user - */ - ent?: string[]; - - /** - * Standard expiry in epoch seconds - */ - exp: number; - - /** - * Standard issue time in epoch seconds - */ - iat: number; -} From d62bc515ecb8435e3dff4543ee18d93542a8d8b9 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 3 Apr 2024 13:44:30 +0200 Subject: [PATCH 10/12] docs: add changeset files Co-authored-by: Patrik Oldsberg Signed-off-by: Camila Belo --- .changeset/hip-ravens-glow.md | 5 +++++ .changeset/nice-squids-complain.md | 5 +++++ .changeset/stale-jeans-tell.md | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 .changeset/hip-ravens-glow.md create mode 100644 .changeset/nice-squids-complain.md create mode 100644 .changeset/stale-jeans-tell.md diff --git a/.changeset/hip-ravens-glow.md b/.changeset/hip-ravens-glow.md new file mode 100644 index 0000000000..82101f00eb --- /dev/null +++ b/.changeset/hip-ravens-glow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Added token type header parameter and user identity proof to issued user tokens. diff --git a/.changeset/nice-squids-complain.md b/.changeset/nice-squids-complain.md new file mode 100644 index 0000000000..6859427883 --- /dev/null +++ b/.changeset/nice-squids-complain.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-node': patch +--- + +Add `tokenTypes` export with constants for various Backstage token types. diff --git a/.changeset/stale-jeans-tell.md b/.changeset/stale-jeans-tell.md new file mode 100644 index 0000000000..b4875b587f --- /dev/null +++ b/.changeset/stale-jeans-tell.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Add support for limited user tokens by using user identity proof provided by the auth backend. From fb09003298357cd35e8c16116c914ca9f41cf953 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 3 Apr 2024 13:53:53 +0200 Subject: [PATCH 11/12] feat: add token backward compatibility Co-authored-by: Patrik Oldsberg Signed-off-by: Camila Belo --- .../implementations/auth/UserTokenHandler.ts | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) 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 078b46d3be..c1197f44a5 100644 --- a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts @@ -67,7 +67,7 @@ export class UserTokenHandler { this.#keyStore, verifyOpts, ).catch(e => { - throw new AuthenticationError('invalid token', e); + throw new AuthenticationError('Invalid token', e); }); const userEntityRef = payload.sub; @@ -98,10 +98,15 @@ export class UserTokenHandler { }; } - return { - algorithms: this.#algorithms, - audience: 'backstage', - }; + const { aud } = decodeJwt(token); + if (aud === 'backstage') { + return { + algorithms: this.#algorithms, + audience: 'backstage', + }; + } + + return undefined; } createLimitedUserToken(backstageToken: string) { @@ -113,12 +118,27 @@ export class UserTokenHandler { new TextDecoder().decode(base64url.decode(payloadRaw)), ); + const tokenType = header.typ; + + // Only new user tokens can be used to create a limited user token. If we + // can't create a limited token, or the token is already a limited one, we + // return the original token + if (!tokenType || tokenType === tokenTypes.limitedUser.typParam) { + return { token: backstageToken, expiresAt: new Date(payload.exp * 1000) }; + } + + if (tokenType !== tokenTypes.user.typParam) { + throw new AuthenticationError( + 'Failed to create limited user token, invalid token type', + ); + } + // NOTE: The order and properties in both the header and payload must match // the usage in plugins/auth-backend/src/identity/TokenFactory.ts const limitedUserToken = [ base64url.encode( JSON.stringify({ - typ: 'vnd.backstage.limited-user', + typ: tokenTypes.limitedUser.typParam, alg: header.alg, kid: header.kid, }), From b00dc09ecee24ed26969f9a51327648b49f4d0af Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 3 Apr 2024 17:10:59 +0200 Subject: [PATCH 12/12] test: cover all type of tokens Signed-off-by: Camila Belo --- .../auth/UserTokenHandler.test.ts | 384 ++++++++++++++++++ .../implementations/auth/UserTokenHandler.ts | 46 ++- 2 files changed, 409 insertions(+), 21 deletions(-) create mode 100644 packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.test.ts diff --git a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.test.ts b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.test.ts new file mode 100644 index 0000000000..82b9b48d29 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.test.ts @@ -0,0 +1,384 @@ +/* + * 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 { JsonObject } from '@backstage/types'; +import { UserTokenHandler } from './UserTokenHandler'; +import { + mockServices, + setupRequestMockHandlers, +} from '@backstage/backend-test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { AuthenticationError } from '@backstage/errors'; +import { SignJWT, GeneralSign, importJWK, base64url } from 'jose'; + +const mockPublicKey = { + kty: 'EC', + x: 'GHlwg744e8JekzukPTdtix6R868D6fcWy0ooOx-NEZI', + y: 'Lyujcm0M6X9_yQi3l1eH09z0brU8K9cwrLml_fRFKro', + crv: 'P-256', + kid: 'mock', + alg: 'ES256', +}; +const mockPrivateKey = { + ...mockPublicKey, + d: 'KEn_mDqXYbZdRHb-JnCrW53LDOv5x4NL1FnlKcqBsFI', +}; + +const server = setupServer(); + +function encodeData(data: JsonObject) { + return base64url.encode(JSON.stringify(data)); +} + +async function createToken(options: { + header: JsonObject; + payload: JsonObject; + signature?: string; +}) { + if (options.signature) { + const header = encodeData(options.header); + const payload = encodeData(options.payload); + + return `${header}.${payload}.${options.signature}`; + } + + return await new SignJWT(options.payload) + .setProtectedHeader({ ...options.header, alg: 'ES256' }) + .sign(await importJWK(mockPrivateKey)); +} + +describe('UserTokenHandler', () => { + let userTokenHandler: UserTokenHandler; + + setupRequestMockHandlers(server); + + beforeEach(() => { + jest.useRealTimers(); + + userTokenHandler = new UserTokenHandler({ + discovery: mockServices.discovery(), + }); + + server.use( + rest.get( + 'http://localhost:0/api/auth/.well-known/jwks.json', + (_req, res, ctx) => + res( + ctx.json({ + keys: [mockPublicKey], + }), + ), + ), + ); + }); + + describe('verifyToken', () => { + it('should return undefined if token format or type is unknown', async () => { + await expect( + userTokenHandler.verifyToken('invalid-token'), + ).resolves.toBeUndefined(); + + await expect( + userTokenHandler.verifyToken('a.b.c'), + ).resolves.toBeUndefined(); + + await expect( + userTokenHandler.verifyToken( + await createToken({ + header: { typ: 'unknown' }, + payload: { sub: 'mock' }, + signature: 'sig', + }), + ), + ).resolves.toBeUndefined(); + }); + + it('should fail to verify tokens with invalid signatures', async () => { + await expect( + userTokenHandler.verifyToken( + await createToken({ + header: { + typ: 'vnd.backstage.user', + alg: 'ES256', + kid: mockPublicKey.kid, + }, + payload: { sub: 'mock' }, + signature: 'sig', + }), + ), + ).rejects.toThrow('signature verification failed'); + + await expect( + userTokenHandler.verifyToken( + await createToken({ + header: { alg: 'ES256', kid: mockPublicKey.kid }, + payload: { aud: 'backstage', sub: 'mock' }, + signature: 'sig', + }), + ), + ).rejects.toThrow('signature verification failed'); + }); + + it('should verify a valid legacy backstage token', async () => { + const expectedIssuedAt = 1712071714; + const expectedExpiresAt = 1712075314; + + jest.useFakeTimers({ + now: expectedIssuedAt * 1000 + 600_000, + }); + + const parts = { + header: { + alg: 'ES256', + kid: mockPublicKey.kid, + }, + payload: { + iss: 'http://localhost:7007/api/auth', + sub: 'user:development/guest', + ent: ['user:development/guest', 'group:default/team-a'], + aud: 'backstage', + iat: 1712071714, + exp: expectedExpiresAt, + }, + }; + + const token = await createToken(parts); + await expect(userTokenHandler.verifyToken(token)).resolves.toEqual({ + userEntityRef: parts.payload.sub, + }); + }); + + it('should fail to verify when the sub claim is missing', async () => { + const expectedIssuedAt = 1712071714; + const expectedExpiresAt = 1712075314; + + jest.useFakeTimers({ + now: expectedIssuedAt * 1000 + 600_000, + }); + + const parts = { + header: { + alg: 'ES256', + kid: mockPublicKey.kid, + }, + payload: { + iss: 'http://localhost:7007/api/auth', + ent: ['user:development/guest', 'group:default/team-a'], + aud: 'backstage', + iat: 1712071714, + exp: expectedExpiresAt, + }, + }; + + const token = await createToken(parts); + await expect(userTokenHandler.verifyToken(token)).rejects.toThrow( + 'No user sub found in token', + ); + }); + + it('should verify a valid user token', async () => { + const expectedIssuedAt = 1712071714; + const expectedExpiresAt = 1712075314; + + jest.useFakeTimers({ + now: expectedIssuedAt * 1000 + 600_000, + }); + + const parts = { + header: { + typ: 'vnd.backstage.user', + alg: 'ES256', + kid: mockPublicKey.kid, + }, + payload: { + iss: 'http://localhost:7007/api/auth', + sub: 'user:development/guest', + ent: ['user:development/guest', 'group:default/team-a'], + aud: 'backstage', + iat: 1712071714, + exp: expectedExpiresAt, + uip: 'proof', + }, + }; + + const token = await createToken(parts); + + await expect(userTokenHandler.verifyToken(token)).resolves.toEqual({ + userEntityRef: parts.payload.sub, + }); + }); + + it('should verify a valid limited user token', async () => { + const expectedIssuedAt = 1712071714; + const expectedExpiresAt = 1712075314; + + jest.useFakeTimers({ + now: expectedIssuedAt * 1000 + 600_000, + }); + + const parts = { + header: { + typ: 'vnd.backstage.limited-user', + alg: 'ES256', + kid: mockPublicKey.kid, + }, + payload: { + sub: 'user:development/guest', + ent: ['user:development/guest', 'group:default/team-a'], + iat: 1712071714, + exp: expectedExpiresAt, + }, + }; + + const token = await createToken(parts); + + await expect(userTokenHandler.verifyToken(token)).resolves.toEqual({ + userEntityRef: parts.payload.sub, + }); + }); + }); + + describe('createLimitedUserToken', () => { + it('should return the original token if it a legacy backstage token', async () => { + const backstageToken = await createToken({ + // Without header.typ param + header: { alg: 'ES256' }, + payload: {}, + }); + const result = userTokenHandler.createLimitedUserToken(backstageToken); + expect(result).toEqual({ + token: backstageToken, + expiresAt: expect.any(Date), + }); + }); + + it('should return the original token if it is already a limited user token', async () => { + const backstageToken = await createToken({ + header: { typ: 'vnd.backstage.user', alg: 'ES256' }, + payload: { sub: 'mock', uip: 'proof' }, + signature: 'some-signature', + }); + const result = userTokenHandler.createLimitedUserToken(backstageToken); + expect(result).toEqual({ + token: await createToken({ + header: { typ: 'vnd.backstage.limited-user', alg: 'ES256' }, + payload: { sub: 'mock' }, + signature: 'proof', + }), + expiresAt: expect.any(Date), + }); + }); + + it('should throw an AuthenticationError if the token type is invalid', async () => { + const backstageToken = await createToken({ + header: { typ: 'invalid' }, + payload: {}, + }); + + expect(() => { + userTokenHandler.createLimitedUserToken(backstageToken); + }).toThrow( + new AuthenticationError( + 'Failed to create limited user token, invalid token type', + ), + ); + }); + + it('should create a limited user token from a user token', async () => { + const backstageToken = await createToken({ + header: { typ: 'vnd.backstage.user', alg: 'ES256' }, + payload: { + aud: 'backstage', + sub: 'mock', + ent: ['mock'], + iat: 1, + exp: 2, + uip: 'proof', + }, + signature: 'sig', + }); + + const result = userTokenHandler.createLimitedUserToken(backstageToken); + expect(result).toEqual({ + token: await createToken({ + header: { typ: 'vnd.backstage.limited-user', alg: 'ES256' }, + payload: { + sub: 'mock', + ent: ['mock'], + iat: 1, + exp: 2, + }, + signature: 'proof', + }), + expiresAt: expect.any(Date), + }); + }); + + it('should create limited token that can be verified', async () => { + jest.useFakeTimers({ + now: 1712071714 * 1000 + 600_000, + }); + const parts = { + header: { + typ: 'vnd.backstage.user', + alg: 'ES256', + kid: mockPublicKey.kid, + }, + payload: { + iss: 'http://localhost:7007/api/auth', + sub: 'user:development/guest', + ent: ['user:development/guest', 'group:default/team-a'], + aud: 'backstage', + iat: 1712071714, + exp: 1712075314, + uip: '01AQB_IjGMtVsh2Zh3dH55xN_oiIYaCQw82cx6y3PP1yiN38xc31ZLKe4aSCBRSO-tr1sdU3OoD-LIa_-5_QUA', + }, + signature: 'sig', + }; + + const { + signatures: [{ signature: uip }], + } = await new GeneralSign( + new TextEncoder().encode( + JSON.stringify({ + sub: parts.payload.sub, + ent: parts.payload.ent, + iat: parts.payload.iat, + exp: parts.payload.exp, + }), + ), + ) + .addSignature(await importJWK(mockPrivateKey)) + .setProtectedHeader({ + ...parts.header, + typ: 'vnd.backstage.limited-user', + }) + .done() + .sign(); + + parts.payload.uip = uip; + const token = await createToken(parts); + + const result = userTokenHandler.createLimitedUserToken(token); + await expect(userTokenHandler.verifyToken(result.token)).resolves.toEqual( + { + userEntityRef: 'user:development/guest', + }, + ); + }); + }); +}); 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 c1197f44a5..3002096b1e 100644 --- a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts @@ -80,30 +80,34 @@ export class UserTokenHandler { } #getTokenVerificationOptions(token: string): JWTVerifyOptions | undefined { - const { typ } = decodeProtectedHeader(token); + try { + const { typ } = decodeProtectedHeader(token); - if (typ === tokenTypes.user.typParam) { - return { - algorithms: this.#algorithms, - requiredClaims: ['iat', 'exp', 'sub'], - typ: tokenTypes.user.typParam, - }; - } + if (typ === tokenTypes.user.typParam) { + return { + algorithms: this.#algorithms, + requiredClaims: ['iat', 'exp', 'sub'], + typ: tokenTypes.user.typParam, + }; + } - if (typ === tokenTypes.limitedUser.typParam) { - return { - algorithms: this.#algorithms, - requiredClaims: ['iat', 'exp', 'sub'], - typ: tokenTypes.limitedUser.typParam, - }; - } + if (typ === tokenTypes.limitedUser.typParam) { + return { + algorithms: this.#algorithms, + requiredClaims: ['iat', 'exp', 'sub'], + typ: tokenTypes.limitedUser.typParam, + }; + } - const { aud } = decodeJwt(token); - if (aud === 'backstage') { - return { - algorithms: this.#algorithms, - audience: 'backstage', - }; + const { aud } = decodeJwt(token); + if (aud === tokenTypes.user.audClaim) { + return { + algorithms: this.#algorithms, + audience: tokenTypes.user.audClaim, + }; + } + } catch { + /* ignore */ } return undefined;