From b128ed97c8146d736dd1125019dad8b096dd643c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 24 Apr 2025 15:32:37 +0200 Subject: [PATCH] auth-backend: refactor to keep token issuers consistent Signed-off-by: Patrik Oldsberg --- .changeset/ten-tables-build.md | 5 + .../src/identity/StaticTokenIssuer.test.ts | 162 ++++++++++----- .../src/identity/StaticTokenIssuer.ts | 65 ++---- .../auth-backend/src/identity/TokenFactory.ts | 165 +-------------- .../src/identity/issueUserToken.ts | 191 ++++++++++++++++++ plugins/auth-backend/src/service/router.ts | 14 +- 6 files changed, 347 insertions(+), 255 deletions(-) create mode 100644 .changeset/ten-tables-build.md create mode 100644 plugins/auth-backend/src/identity/issueUserToken.ts diff --git a/.changeset/ten-tables-build.md b/.changeset/ten-tables-build.md new file mode 100644 index 0000000000..9ddae808fa --- /dev/null +++ b/.changeset/ten-tables-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +The `static` key store now issues tokens with the same structure as other key stores. Tokens now include the `typ` field in the header and the `uip` (user identity proof) in the payload. diff --git a/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts b/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts index 1b9b5311ba..ef6f7bd148 100644 --- a/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts +++ b/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts @@ -18,6 +18,8 @@ import { createLocalJWKSet, jwtVerify } from 'jose'; import { stringifyEntityRef } from '@backstage/catalog-model'; import { StaticKeyStore } from './StaticKeyStore'; import { mockServices } from '@backstage/backend-test-utils'; +import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler'; +import { omit } from 'lodash'; const logger = mockServices.logger.mock(); const entityRef = stringifyEntityRef({ @@ -27,60 +29,64 @@ const entityRef = stringifyEntityRef({ }); describe('StaticTokenIssuer', () => { - it('should issue valid tokens signed by the first listed key', async () => { - const staticKeyStore = { - listKeys: () => { - return Promise.resolve({ - items: [ - { - createdAt: new Date(), - key: { - kty: 'EC', - x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w', - y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc', - crv: 'P-256', - kid: '1', - alg: 'ES256', - use: 'sig', - }, - }, - { - createdAt: new Date(), - key: { - kty: 'EC', - x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w', - y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc', - crv: 'P-256', - kid: '2', - alg: 'ES256', - use: 'sig', - }, - }, - ], - }); - }, - getPrivateKey: (kid: string) => { - expect(kid).toEqual('1'); - return { - kty: 'EC', - x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w', - y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc', - crv: 'P-256', - d: 'R8Ja2ppMEgOm1KeYKpje00U1luybndt6yC263vcgeKo', - kid: '1', - alg: 'ES256', - }; - }, - }; + const mockUserInfoDatabaseHandler = { + addUserInfo: jest.fn().mockResolvedValue(undefined), + } as unknown as UserInfoDatabaseHandler; + const staticKeyStore = { + listKeys: () => { + return Promise.resolve({ + items: [ + { + createdAt: new Date(), + key: { + kty: 'EC', + x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w', + y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc', + crv: 'P-256', + kid: '1', + alg: 'ES256', + use: 'sig', + }, + }, + { + createdAt: new Date(), + key: { + kty: 'EC', + x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w', + y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc', + crv: 'P-256', + kid: '2', + alg: 'ES256', + use: 'sig', + }, + }, + ], + }); + }, + getPrivateKey: (kid: string) => { + expect(kid).toEqual('1'); + return { + kty: 'EC', + x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w', + y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc', + crv: 'P-256', + d: 'R8Ja2ppMEgOm1KeYKpje00U1luybndt6yC263vcgeKo', + kid: '1', + alg: 'ES256', + }; + }, + }; + + it('should issue valid tokens signed by the first listed key', async () => { const keyDurationSeconds = 86400; - const options = { - logger, - issuer: 'my-issuer', - sessionExpirationSeconds: keyDurationSeconds, - }; const issuer = new StaticTokenIssuer( - options, + { + logger, + issuer: 'my-issuer', + sessionExpirationSeconds: keyDurationSeconds, + userInfoDatabaseHandler: mockUserInfoDatabaseHandler, + }, staticKeyStore as unknown as StaticKeyStore, ); const { token } = await issuer.issueToken({ @@ -98,6 +104,7 @@ describe('StaticTokenIssuer', () => { expect(verifyResult.protectedHeader).toEqual({ kid: '1', alg: 'ES256', + typ: 'vnd.backstage.user', }); expect(verifyResult.payload).toEqual({ iss: 'my-issuer', @@ -107,9 +114,62 @@ describe('StaticTokenIssuer', () => { '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, ); + expect(mockUserInfoDatabaseHandler.addUserInfo).toHaveBeenCalledWith({ + claims: omit(verifyResult.payload, ['aud', 'iat', 'iss', 'uip']), + }); + }); + + it('should issue valid tokens with omitted claims', async () => { + const keyDurationSeconds = 86400; + const issuer = new StaticTokenIssuer( + { + logger, + issuer: 'my-issuer', + sessionExpirationSeconds: keyDurationSeconds, + userInfoDatabaseHandler: mockUserInfoDatabaseHandler, + omitClaimsFromToken: ['ent'], + }, + staticKeyStore as unknown as StaticKeyStore, + ); + const { token } = await issuer.issueToken({ + claims: { + sub: entityRef, + ent: [entityRef], + 'x-fancy-claim': 'my special claim', + aud: 'this value will be overridden', + }, + }); + + const { keys } = await issuer.listPublicKeys(); + const keyStore = createLocalJWKSet({ keys: keys }); + const verifyResult = await jwtVerify(token, keyStore); + expect(verifyResult.protectedHeader).toEqual({ + kid: '1', + alg: 'ES256', + typ: 'vnd.backstage.user', + }); + expect(verifyResult.payload).toEqual({ + iss: 'my-issuer', + aud: 'backstage', + sub: 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, + ); + expect(mockUserInfoDatabaseHandler.addUserInfo).toHaveBeenCalledWith({ + claims: { + ...omit(verifyResult.payload, ['aud', 'iat', 'iss', 'uip']), + ent: [entityRef], + }, + }); }); }); diff --git a/plugins/auth-backend/src/identity/StaticTokenIssuer.ts b/plugins/auth-backend/src/identity/StaticTokenIssuer.ts index 827dbc09ba..6875e47457 100644 --- a/plugins/auth-backend/src/identity/StaticTokenIssuer.ts +++ b/plugins/auth-backend/src/identity/StaticTokenIssuer.ts @@ -15,17 +15,15 @@ */ import { AnyJWK, TokenIssuer } from './types'; -import { SignJWT, importJWK, JWK } from 'jose'; -import { parseEntityRef } from '@backstage/catalog-model'; -import { AuthenticationError } from '@backstage/errors'; +import { JWK } from 'jose'; import { LoggerService } from '@backstage/backend-plugin-api'; import { StaticKeyStore } from './StaticKeyStore'; import { BackstageSignInResult, TokenParams, } from '@backstage/plugin-auth-node'; - -const MS_IN_S = 1000; +import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler'; +import { issueUserToken } from './issueUserToken'; export type Config = { publicKeyFile: string; @@ -40,6 +38,11 @@ export type Options = { issuer: string; /** Expiration time of the JWT in seconds */ sessionExpirationSeconds: number; + /** + * A list of claims to omit from issued tokens and only store in the user info database + */ + omitClaimsFromToken?: string[]; + userInfoDatabaseHandler: UserInfoDatabaseHandler; }; /** @@ -51,56 +54,30 @@ export class StaticTokenIssuer implements TokenIssuer { private readonly logger: LoggerService; private readonly keyStore: StaticKeyStore; private readonly sessionExpirationSeconds: number; + private readonly omitClaimsFromToken?: string[]; + private readonly userInfoDatabaseHandler: UserInfoDatabaseHandler; public constructor(options: Options, keyStore: StaticKeyStore) { this.issuer = options.issuer; this.logger = options.logger; this.sessionExpirationSeconds = options.sessionExpirationSeconds; this.keyStore = keyStore; + this.omitClaimsFromToken = options.omitClaimsFromToken; + this.userInfoDatabaseHandler = options.userInfoDatabaseHandler; } public async issueToken(params: TokenParams): Promise { const key = await this.getSigningKey(); - // TODO: code shared with TokenFactory.ts - const iss = this.issuer; - const { sub, ent, ...additionalClaims } = params.claims; - const aud = 'backstage'; - const iat = Math.floor(Date.now() / MS_IN_S); - const exp = iat + this.sessionExpirationSeconds; - - // Validate that the subject claim is a valid EntityRef - try { - parseEntityRef(sub); - } catch (error) { - throw new Error( - '"sub" claim provided by the auth resolver is not a valid EntityRef.', - ); - } - - this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`); - - if (!key.alg) { - throw new AuthenticationError('No algorithm was provided in the key'); - } - - const token = await new SignJWT({ - ...additionalClaims, - iss, - sub, - ent, - aud, - iat, - exp, - }) - .setProtectedHeader({ alg: key.alg, kid: key.kid }) - .setIssuer(iss) - .setAudience(aud) - .setSubject(sub) - .setIssuedAt(iat) - .setExpirationTime(exp) - .sign(await importJWK(key)); - return { token }; + return issueUserToken({ + issuer: this.issuer, + key, + keyDurationSeconds: this.sessionExpirationSeconds, + logger: this.logger, + omitClaimsFromToken: this.omitClaimsFromToken, + params, + userInfoDatabaseHandler: this.userInfoDatabaseHandler, + }); } private async getSigningKey(): Promise { diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index 28addbbaeb..4c654e5202 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -14,18 +14,7 @@ * limitations under the License. */ -import { parseEntityRef } from '@backstage/catalog-model'; -import { AuthenticationError } from '@backstage/errors'; -import { - exportJWK, - generateKeyPair, - importJWK, - JWK, - SignJWT, - GeneralSign, - KeyLike, -} from 'jose'; -import { omit } from 'lodash'; +import { exportJWK, generateKeyPair, JWK } from 'jose'; import { DateTime } from 'luxon'; import { v4 as uuid } from 'uuid'; import { LoggerService } from '@backstage/backend-plugin-api'; @@ -37,9 +26,7 @@ import { import { AnyJWK, KeyStore, TokenIssuer } from './types'; import { JsonValue } from '@backstage/types'; import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler'; - -const MS_IN_S = 1000; -const MAX_TOKEN_LENGTH = 32768; // At 64 bytes per entity ref this still leaves room for about 500 entities +import { issueUserToken } from './issueUserToken'; /** * The payload contents of a valid Backstage JWT token @@ -86,28 +73,6 @@ export interface BackstageTokenPayload { [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; - - /** - * 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 */ @@ -169,83 +134,15 @@ export class TokenFactory implements TokenIssuer { async issueToken(params: TokenParams): Promise { const key = await this.getKey(); - const iss = this.issuer; - 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; - - try { - // The subject must be a valid entity ref - parseEntityRef(sub); - } catch (error) { - throw new Error( - '"sub" claim provided by the auth resolver is not a valid EntityRef.', - ); - } - - if (!key.alg) { - throw new AuthenticationError('No algorithm was provided in the key'); - } - - this.logger.info(`Issuing token for ${sub}, with entities ${ent}`); - - const signingKey = await importJWK(key); - - const uip = await this.createUserIdentityClaim({ - header: { - typ: tokenTypes.limitedUser.typParam, - alg: key.alg, - kid: key.kid, - }, - payload: { sub, iat, exp }, - key: signingKey, + return issueUserToken({ + issuer: this.issuer, + key, + keyDurationSeconds: this.keyDurationSeconds, + logger: this.logger, + omitClaimsFromToken: this.omitClaimsFromToken, + params, + userInfoDatabaseHandler: this.userInfoDatabaseHandler, }); - - const claims: BackstageTokenPayload = { - ...additionalClaims, - iss, - sub, - ent, - aud, - iat, - exp, - uip, - }; - - const tokenClaims = this.omitClaimsFromToken - ? omit(claims, this.omitClaimsFromToken) - : claims; - const token = await new SignJWT(tokenClaims) - .setProtectedHeader({ - typ: tokenTypes.user.typParam, - alg: key.alg, - kid: key.kid, - }) - .sign(signingKey); - - if (token.length > MAX_TOKEN_LENGTH) { - throw new Error( - `Failed to issue a new user token. The resulting token is excessively large, with either too many ownership claims or too large custom claims. You likely have a bug either in the sign-in resolver or catalog data. The following claims were requested: '${JSON.stringify( - tokenClaims, - )}'`, - ); - } - - // Store the user info in the database upon successful token - // issuance so that it can be retrieved later by limited user tokens - await this.userInfoDatabaseHandler.addUserInfo({ - claims: omit(claims, ['aud', 'iat', 'iss', 'uip']), - }); - - return { - token, - identity: { - type: 'user', - userEntityRef: sub, - ownershipEntityRefs: ent, - }, - }; } // This will be called by other services that want to verify ID tokens. @@ -338,46 +235,4 @@ 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: { - typ: string; - 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 = { - typ: options.header.typ, - alg: options.header.alg, - ...(options.header.kid ? { kid: options.header.kid } : {}), - }; - - const payload = { - 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-backend/src/identity/issueUserToken.ts b/plugins/auth-backend/src/identity/issueUserToken.ts new file mode 100644 index 0000000000..d411439e6e --- /dev/null +++ b/plugins/auth-backend/src/identity/issueUserToken.ts @@ -0,0 +1,191 @@ +/* + * Copyright 2025 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 { parseEntityRef } from '@backstage/catalog-model'; +import { AuthenticationError } from '@backstage/errors'; +import { + BackstageSignInResult, + TokenParams, + tokenTypes, +} from '@backstage/plugin-auth-node'; +import { omit } from 'lodash'; +import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { GeneralSign, importJWK, JWK, KeyLike, SignJWT } from 'jose'; +import { BackstageTokenPayload } from './TokenFactory'; + +const MS_IN_S = 1000; +const MAX_TOKEN_LENGTH = 32768; // At 64 bytes per entity ref this still leaves room for about 500 entities + +export async function issueUserToken({ + issuer, + key, + keyDurationSeconds, + logger, + omitClaimsFromToken, + params, + userInfoDatabaseHandler, +}: { + issuer: string; + key: JWK; + keyDurationSeconds: number; + logger: LoggerService; + omitClaimsFromToken?: string[]; + params: TokenParams; + userInfoDatabaseHandler: UserInfoDatabaseHandler; +}): Promise { + const { sub, ent = [sub], ...additionalClaims } = params.claims; + const aud = tokenTypes.user.audClaim; + const iat = Math.floor(Date.now() / MS_IN_S); + const exp = iat + keyDurationSeconds; + + try { + // The subject must be a valid entity ref + parseEntityRef(sub); + } catch (error) { + throw new Error( + '"sub" claim provided by the auth resolver is not a valid EntityRef.', + ); + } + + if (!key.alg) { + throw new AuthenticationError('No algorithm was provided in the key'); + } + + logger.info(`Issuing token for ${sub}, with entities ${ent}`); + + const signingKey = await importJWK(key); + + const uip = await createUserIdentityClaim({ + header: { + typ: tokenTypes.limitedUser.typParam, + alg: key.alg, + kid: key.kid, + }, + payload: { sub, iat, exp }, + key: signingKey, + }); + + const claims: BackstageTokenPayload = { + ...additionalClaims, + iss: issuer, + sub, + ent, + aud, + iat, + exp, + uip, + }; + + const tokenClaims = omitClaimsFromToken + ? omit(claims, omitClaimsFromToken) + : claims; + const token = await new SignJWT(tokenClaims) + .setProtectedHeader({ + typ: tokenTypes.user.typParam, + alg: key.alg, + kid: key.kid, + }) + .sign(signingKey); + + if (token.length > MAX_TOKEN_LENGTH) { + throw new Error( + `Failed to issue a new user token. The resulting token is excessively large, with either too many ownership claims or too large custom claims. You likely have a bug either in the sign-in resolver or catalog data. The following claims were requested: '${JSON.stringify( + tokenClaims, + )}'`, + ); + } + + // Store the user info in the database upon successful token + // issuance so that it can be retrieved later by limited user tokens + await userInfoDatabaseHandler.addUserInfo({ + claims: omit(claims, ['aud', 'iat', 'iss', 'uip']), + }); + + return { + token, + identity: { + type: 'user', + userEntityRef: sub, + ownershipEntityRefs: ent, + }, + }; +} + +/** + * The payload contents of a valid Backstage user identity claim token + * + * @internal + */ +interface BackstageUserIdentityProofPayload { + /** + * The entity ref of the user + */ + sub: string; + + /** + * Standard expiry in epoch seconds + */ + exp: number; + + /** + * Standard issue time in epoch seconds + */ + iat: number; +} + +/** + * 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. + */ +async function createUserIdentityClaim(options: { + header: { + typ: string; + 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 = { + typ: options.header.typ, + alg: options.header.alg, + ...(options.header.kid ? { kid: options.header.kid } : {}), + }; + + const payload = { + 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-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 3f53a111a1..fd9373d5a3 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -81,6 +81,12 @@ export async function createRouter( await authDb.get(), ); + const omitClaimsFromToken = config.getOptionalBoolean( + 'auth.omitIdentityTokenOwnershipClaim', + ) + ? ['ent'] + : []; + let tokenIssuer: TokenIssuer; if (keyStore instanceof StaticKeyStore) { tokenIssuer = new StaticTokenIssuer( @@ -88,6 +94,8 @@ export async function createRouter( logger: logger.child({ component: 'token-factory' }), issuer: authUrl, sessionExpirationSeconds: backstageTokenExpiration, + userInfoDatabaseHandler, + omitClaimsFromToken, }, keyStore as StaticKeyStore, ); @@ -101,11 +109,7 @@ export async function createRouter( tokenFactoryAlgorithm ?? config.getOptionalString('auth.identityTokenAlgorithm'), userInfoDatabaseHandler, - omitClaimsFromToken: config.getOptionalBoolean( - 'auth.omitIdentityTokenOwnershipClaim', - ) - ? ['ent'] - : [], + omitClaimsFromToken, }); }