From f2af9ba6b174a564993731eb2c3cf55ef9c8bd13 Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Mon, 4 May 2026 15:10:46 -0400 Subject: [PATCH] fix(backend): verify array jwt audiences --- .../src/jwt/__tests__/assertions.test.ts | 6 +++ .../src/jwt/__tests__/verifyJwt.test.ts | 47 +++++++++++++++++++ packages/backend/src/jwt/verifyJwt.ts | 2 +- 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/jwt/__tests__/assertions.test.ts b/packages/backend/src/jwt/__tests__/assertions.test.ts index 0dea86341d6..294976aef8d 100644 --- a/packages/backend/src/jwt/__tests__/assertions.test.ts +++ b/packages/backend/src/jwt/__tests__/assertions.test.ts @@ -93,6 +93,12 @@ describe('assertAudienceClaim(audience?, aud?)', () => { ); }); + it('throws error when audience string[] has no intersection with aud string[]', () => { + expect(() => assertAudienceClaim([audience], [invalidAudience])).toThrow( + `Invalid JWT audience claim array (aud) ${JSON.stringify([audience])}. Is not included in "${JSON.stringify([invalidAudience])}".`, + ); + }); + it('throws error when aud is a substring of audience', () => { expect(() => assertAudienceClaim(audience.slice(0, -2), audience)).toThrow( `Invalid JWT audience claim (aud) "${audience.slice(0, -2)}". Is not included in "${JSON.stringify([audience])}".`, diff --git a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts index d2f529251d8..d320adb8e23 100644 --- a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts +++ b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts @@ -5,14 +5,17 @@ import { mockJwks, mockJwt, mockJwtHeader, + mockM2MJwtPayload, mockJwtPayload, mockOAuthAccessTokenJwtPayload, pemEncodedPublicKey, + pemEncodedSignKey, publicJwks, signedJwt, someOtherPublicKey, } from '../../fixtures'; import { mockSignedOAuthAccessTokenJwt, mockSignedOAuthAccessTokenJwtApplicationTyp } from '../../fixtures/machine'; +import { signJwt } from '../signJwt'; import { decodeJwt, hasValidSignature, verifyJwt } from '../verifyJwt'; const invalidTokenError = { @@ -218,6 +221,50 @@ describe('verifyJwt(jwt, options)', () => { expect(error?.message).toContain('Expected "at+jwt, application/at+jwt"'); }); + it('verifies JWT when array aud includes the configured audience', async () => { + const audience = 'https://my-resource.example.com'; + const { data: jwtWithArrayAud } = await signJwt( + { + ...mockM2MJwtPayload, + aud: ['https://other-resource.example.com', audience], + }, + pemEncodedSignKey, + { + algorithm: mockJwtHeader.alg, + header: mockJwtHeader, + }, + ); + + const { data } = await verifyJwt(jwtWithArrayAud || '', { + key: pemEncodedPublicKey, + audience, + }); + + expect(data?.aud).toEqual(['https://other-resource.example.com', audience]); + }); + + it('rejects JWT when array aud does not include the configured audience', async () => { + const { data: jwtWithArrayAud } = await signJwt( + { + ...mockM2MJwtPayload, + aud: ['https://attacker.example.com'], + }, + pemEncodedSignKey, + { + algorithm: mockJwtHeader.alg, + header: mockJwtHeader, + }, + ); + + const { errors: [error] = [] } = await verifyJwt(jwtWithArrayAud || '', { + key: pemEncodedPublicKey, + audience: 'https://my-resource.example.com', + }); + + expect(error).toBeDefined(); + expect(error?.message).toContain('Invalid JWT audience claim array'); + }); + it('rejects an expired JWT when clockSkewInMs is explicitly 0', async () => { vi.setSystemTime(new Date((mockJwtPayload.exp + 1) * 1000)); const inputVerifyJwtOptions = { diff --git a/packages/backend/src/jwt/verifyJwt.ts b/packages/backend/src/jwt/verifyJwt.ts index b96055126c4..fa0785af73b 100644 --- a/packages/backend/src/jwt/verifyJwt.ts +++ b/packages/backend/src/jwt/verifyJwt.ts @@ -181,7 +181,7 @@ export async function verifyJwt( const { azp, sub, aud, iat, exp, nbf } = payload; assertSubClaim(sub); - assertAudienceClaim([aud], [audience]); + assertAudienceClaim(aud, audience); assertAuthorizedPartiesClaim(azp, authorizedParties); assertExpirationClaim(exp, clockSkew); assertActivationClaim(nbf, clockSkew);