diff --git a/.changeset/seven-shoes-call.md b/.changeset/seven-shoes-call.md new file mode 100644 index 00000000000..deda4688b22 --- /dev/null +++ b/.changeset/seven-shoes-call.md @@ -0,0 +1,5 @@ +--- +"@clerk/backend": patch +--- + +Fix JWT array audience validation diff --git a/packages/backend/src/jwt/__tests__/assertions.test.ts b/packages/backend/src/jwt/__tests__/assertions.test.ts index f1ccef09fe8..63ac0169296 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 c1a6ab4cbe6..d14e536d2e7 100644 --- a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts +++ b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts @@ -6,13 +6,16 @@ import { mockJwt, mockJwtHeader, mockJwtPayload, + mockM2MJwtPayload, 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 = { @@ -234,6 +237,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);