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. 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.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 new file mode 100644 index 0000000000..3002096b1e --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts @@ -0,0 +1,196 @@ +/* + * 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 { + base64url, + createRemoteJWKSet, + decodeJwt, + decodeProtectedHeader, + FlattenedJWSInput, + JWSHeaderParameters, + jwtVerify, + JWTVerifyOptions, +} 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 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 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); + }); + + const userEntityRef = payload.sub; + + if (!userEntityRef) { + throw new AuthenticationError('No user sub found in token'); + } + + return { userEntityRef }; + } + + #getTokenVerificationOptions(token: string): JWTVerifyOptions | undefined { + 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.limitedUser.typParam) { + return { + algorithms: this.#algorithms, + requiredClaims: ['iat', 'exp', 'sub'], + typ: tokenTypes.limitedUser.typParam, + }; + } + + const { aud } = decodeJwt(token); + if (aud === tokenTypes.user.audClaim) { + return { + algorithms: this.#algorithms, + audience: tokenTypes.user.audClaim, + }; + } + } catch { + /* ignore */ + } + + return undefined; + } + + 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 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: tokenTypes.limitedUser.typParam, + 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 + */ + 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 4600110b5c..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,18 +17,23 @@ import { ServiceFactoryTester, mockServices, + setupRequestMockHandlers, } from '@backstage/backend-test-utils'; import { InternalBackstageCredentials, authServiceFactory, } from './authServiceFactory'; -import { decodeJwt } from 'jose'; +import { base64url, decodeJwt } from 'jose'; import { discoveryServiceFactory } from '../discovery'; import { BackstageServicePrincipal, 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, @@ -127,4 +138,90 @@ describe('authServiceFactory', () => { }), ); }); + + it('should issue limited user tokens', async () => { + 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, + }); + + const catalogAuth = await tester.get('catalog'); + + const fullToken = + 'eyJ0eXAiOiJ2bmQuYmFja3N0YWdlLnVzZXIiLCJhbGciOiJFUzI1NiIsImtpZCI6IjhkMDFjM2RiLTU2ZjktNDVmMC04NmRkLTA1YjNjODM1YjNkMyJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJ1c2VyOmRldmVsb3BtZW50L2d1ZXN0IiwiZW50IjpbInVzZXI6ZGV2ZWxvcG1lbnQvZ3Vlc3QiLCJncm91cDpkZWZhdWx0L3RlYW0tYSJdLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE3MTIwNzE3MTQsImV4cCI6MTcxMjA3NTMxNCwidWlwIjoiMDFBUUJfSWpHTXRWc2gyWmgzZEg1NXhOX29pSVlhQ1F3ODJjeDZ5M1BQMXlpTjM4eGMzMVpMS2U0YVNDQlJTTy10cjFzZFUzT29ELUxJYV8tNV9RVUEifQ.mjIrZGqbZ2t68fS4U3crlGw-bYJZnMlhMHf-YL7q_u1HfaLr4NMTcHkxdnNS2wfJxCmUBxRfUS8b3nSAKsxcHA'; + + 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(expectedExpiresAt * 1000)); + + const expectedTokenHeader = base64url.encode( + JSON.stringify({ + typ: 'vnd.backstage.limited-user', + alg: 'ES256', + kid: '8d01c3db-56f9-45f0-86dd-05b3c835b3d3', + }), + ); + const expectedTokenPayload = base64url.encode( + JSON.stringify({ + sub: 'user:development/guest', + ent: ['user:development/guest', 'group:default/team-a'], + iat: expectedIssuedAt, + exp: expectedExpiresAt, + }), + ); + 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, + }); + + 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 ac5c1f9399..d39c57d328 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -22,13 +22,12 @@ import { BackstageServicePrincipal, BackstageNonePrincipal, BackstageUserPrincipal, - IdentityService, coreServices, createServiceFactory, } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; -import { IdentityApiGetIdentityRequest } from '@backstage/plugin-auth-node'; import { decodeJwt } from 'jose'; +import { UserTokenHandler } from './UserTokenHandler'; /** @internal */ export type InternalBackstageCredentials = @@ -105,7 +104,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, ) {} @@ -120,22 +119,16 @@ class DefaultAuthService implements AuthService { return createCredentialsWithServicePrincipal('external:backstage-plugin'); } - // User Backstage token - const identity = await this.identity.getIdentity({ - request: { - headers: { authorization: `Bearer ${token}` }, - }, - } as IdentityApiGetIdentityRequest); - - if (!identity) { - throw new AuthenticationError('Invalid user token'); + const userResult = await this.userTokenHandler.verifyToken(token); + if (userResult) { + return createCredentialsWithUserPrincipal( + userResult.userEntityRef, + token, + this.#getJwtExpiration(token), + ); } - return createCredentialsWithUserPrincipal( - identity.identity.userEntityRef, - token, - this.#getJwtExpiration(token), - ); + throw new AuthenticationError('Unknown token'); } isPrincipal( @@ -204,17 +197,15 @@ 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) }; + return this.userTokenHandler.createLimitedUserToken(backstageToken); } #getJwtExpiration(token: string) { @@ -232,15 +223,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', @@ -248,7 +239,7 @@ export const authServiceFactory = createServiceFactory({ ); return new DefaultAuthService( tokenManager, - identity, + new UserTokenHandler({ discovery }), plugin.getId(), disableDefaultAuthPolicy, ); diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index e1dd12333d..0994e44f68 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -13,12 +13,18 @@ * 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 { tokenTypes } from '@backstage/plugin-auth-node'; const logger = getVoidLogger(); @@ -60,18 +66,54 @@ describe('TokenFactory', () => { const keyStore = createLocalJWKSet({ keys: keys }); const verifyResult = await jwtVerify(token, keyStore); + expect(verifyResult.protectedHeader.typ).toBe(tokenTypes.user.typParam); expect(verifyResult.payload).toEqual({ 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({ + typ: tokenTypes.limitedUser.typParam, + alg: verifyResult.protectedHeader.alg, + kid: verifyResult.protectedHeader.kid!, + }), + ), + base64url.encode( + JSON.stringify({ + sub: verifyResult.payload.sub, + ent: verifyResult.payload.ent, + iat: verifyResult.payload.iat, + exp: verifyResult.payload.exp, + }), + ), + verifyResult.payload.uip, + ].join('.'); + + const verifyProofResult = await jwtVerify(limitedUserToken, keyStore); + expect(verifyProofResult.protectedHeader.typ).toBe( + tokenTypes.limitedUser.typParam, + ); + expect(verifyProofResult.payload).toEqual({ + sub: entityRef, + ent: [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..9277361679 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -16,16 +16,99 @@ 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 { 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 */ @@ -79,13 +162,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 +176,42 @@ 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: { + typ: tokenTypes.limitedUser.typParam, + alg: key.alg, + kid: key.kid, + }, + payload: { sub, ent, iat, exp }, + key: signingKey, + }); + + const claims: BackstageTokenPayload = { + ...additionalClaims, + 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)); + .setProtectedHeader({ + typ: tokenTypes.user.typParam, + alg: key.alg, + kid: key.kid, + }) + .sign(signingKey); if (token.length > MAX_TOKEN_LENGTH) { throw new Error( @@ -210,4 +314,47 @@ 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, + ent: options.payload.ent, + 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..f9d16c4c3a 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -639,6 +639,20 @@ export type TokenParams = { } & Record; }; +// @public +export const tokenTypes: Readonly<{ + user: Readonly<{ + typParam: 'vnd.backstage.user'; + audClaim: 'backstage'; + }>; + limitedUser: Readonly<{ + typParam: 'vnd.backstage.limited-user'; + }>; + service: Readonly<{ + typParam: '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..8b35aaa1e1 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -29,8 +29,8 @@ export * from './proxy'; export * from './sign-in'; export type { AuthProviderConfig, - AuthProviderRouteHandlers, AuthProviderFactory, + AuthProviderRouteHandlers, AuthResolverCatalogUserQuery, AuthResolverContext, BackstageIdentityResponse, @@ -44,3 +44,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..8cc48aacc4 100644 --- a/plugins/auth-node/src/types.ts +++ b/plugins/auth-node/src/types.ts @@ -375,3 +375,21 @@ 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({ + typParam: 'vnd.backstage.user', + audClaim: 'backstage', + }), + limitedUser: Object.freeze({ + typParam: 'vnd.backstage.limited-user', + }), + service: Object.freeze({ + typParam: 'vnd.backstage.service', + }), +}); 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