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] 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; +}