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;