diff --git a/.changeset/type-jwt-aud-rfc-8707.md b/.changeset/type-jwt-aud-rfc-8707.md new file mode 100644 index 00000000000..e9db961804b --- /dev/null +++ b/.changeset/type-jwt-aud-rfc-8707.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': minor +'@clerk/backend': minor +--- + +Type the JWT `aud` claim as an optional `string | string[]` per RFC 7519 and expose it on verified OAuth JWT access tokens. Clerk-issued OAuth access tokens may include a single RFC 8707 resource URI as a string. diff --git a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts index 8399057e59b..71cc9361b4c 100644 --- a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts +++ b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts @@ -25,6 +25,8 @@ export class IdPOAuthAccessToken { readonly createdAt: number, /** The Unix timestamp (in milliseconds) when the access token was last updated. */ readonly updatedAt: number, + /** The intended audience for the access token. */ + readonly aud?: string | string[], ) {} static fromJSON(data: IdPOAuthAccessTokenJSON) { @@ -40,6 +42,7 @@ export class IdPOAuthAccessToken { data.expiration, data.created_at, data.updated_at, + data.aud, ); } @@ -59,10 +62,11 @@ export class IdPOAuthAccessToken { oauthPayload.scp ?? oauthPayload.scope?.split(' ') ?? [], false, null, - payload.exp * 1000 <= Date.now() - clockSkewInMs, - payload.exp * 1000, // milliseconds: expiration, converted from JWT exp claim - payload.iat * 1000, // milliseconds: createdAt, converted from JWT iat claim - payload.iat * 1000, // milliseconds: updatedAt, no JWT equivalent, defaults to iat + oauthPayload.exp * 1000 <= Date.now() - clockSkewInMs, + oauthPayload.exp * 1000, // milliseconds: expiration, converted from JWT exp claim + oauthPayload.iat * 1000, // milliseconds: createdAt, converted from JWT iat claim + oauthPayload.iat * 1000, // milliseconds: updatedAt, no JWT equivalent, defaults to iat + oauthPayload.aud, ); } } diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index e1ff98e2ee1..69ee1e82b4d 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -962,6 +962,7 @@ export interface IdPOAuthAccessTokenJSON extends ClerkResourceJSON { expiration: number | null; created_at: number; updated_at: number; + aud?: string | string[]; } export interface BillingPayerJSON extends ClerkResourceJSON { diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index 5b19fdd9603..2132ffc5f33 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -7,7 +7,7 @@ type M2MJwtPayload = { exp: number; iat: number; jti?: string; - aud?: string[]; + aud?: string | string[]; scopes?: string; [key: string]: unknown; }; @@ -82,10 +82,19 @@ export class M2MToken { } static fromJwtPayload(payload: M2MJwtPayload, clockSkewInMs = 5000): M2MToken { + let audience: string[] = []; + // If audience is an array, use it directly; + // If it's a string, wrap it in an array; + // If it's undefined, leave it as an empty array. + if (Array.isArray(payload.aud)) { + audience = payload.aud; + } else if (payload.aud) { + audience = [payload.aud]; + } return new M2MToken( payload.jti ?? '', // jti should always be present in Clerk-issued M2M JWTs payload.sub, - payload.scopes?.split(' ') ?? payload.aud ?? [], + payload.scopes?.split(' ') ?? audience, extractCustomClaims(payload), false, null, diff --git a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts index a0b440430f8..8e4bffd275b 100644 --- a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts +++ b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts @@ -105,6 +105,21 @@ describe('M2MToken', () => { expect(token.scopes).toEqual(['scope1', 'scope2', 'scope3']); }); + it('seeds scopes from a string aud claim', () => { + const payload = { + sub: 'mch_test', + exp: 1666648550, + iat: 1666648250, + jti: 'mt_test', + aud: 'https://my-resource.example.com', + }; + + const token = M2MToken.fromJwtPayload(payload); + + expect(token.scopes).toEqual(['https://my-resource.example.com']); + expect(token.claims).toEqual({ aud: 'https://my-resource.example.com' }); + }); + it('returns empty scopes when neither aud nor scopes present', () => { const payload = { sub: 'mch_test', diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 6a12115c01a..603b858a090 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -22,7 +22,7 @@ import { JWT_CATEGORY_M2M_TOKEN } from '../jwtCategories'; import { verifyMachineAuthToken, verifyToken } from '../verify'; async function createSignedOAuthJwt( - payload = mockOAuthAccessTokenJwtPayload, + payload: Record = mockOAuthAccessTokenJwtPayload, typ: 'at+jwt' | 'application/at+jwt' | 'JWT' = 'at+jwt', ) { const { data } = await signJwt(payload, signingJwks, { @@ -558,7 +558,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { payload.sub = sub; } - const oauthJwt = await createSignedOAuthJwt(payload as typeof mockOAuthAccessTokenJwtPayload, 'at+jwt'); + const oauthJwt = await createSignedOAuthJwt(payload, 'at+jwt'); const result = await verifyMachineAuthToken(oauthJwt, { apiUrl: 'https://api.clerk.test', @@ -569,6 +569,81 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.tokenType).toBe('oauth_token'); }, ); + + it('verifies OAuth JWT with a matching RFC 8707 resource audience', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const audience = 'https://my-resource.example.com'; + const oauthJwt = await createSignedOAuthJwt({ + ...mockOAuthAccessTokenJwtPayload, + aud: audience, + }); + + const result = await verifyMachineAuthToken(oauthJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + audience, + }); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toMatchInlineSnapshot(` + IdPOAuthAccessToken { + "aud": "https://my-resource.example.com", + "clientId": "client_2VTWUzvGC5UhdJCNx6xG1D98edc", + "createdAt": 1666648250000, + "expiration": 1666648550000, + "expired": false, + "id": "oat_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE", + "revocationReason": null, + "revoked": false, + "scopes": [ + "read:foo", + "write:bar", + ], + "subject": "user_2vYVtestTESTtestTESTtestTESTtest", + "type": "oauth_token", + "updatedAt": 1666648250000, + } + `); + expect((result.data as IdPOAuthAccessToken).aud).toBe(audience); + expect(result.errors).toBeUndefined(); + }); + + it('rejects OAuth JWT with a mismatched RFC 8707 resource audience', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const oauthJwt = await createSignedOAuthJwt({ + ...mockOAuthAccessTokenJwtPayload, + aud: 'https://attacker.example.com', + }); + + const result = await verifyMachineAuthToken(oauthJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + audience: 'https://my-resource.example.com', + }); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toBeUndefined(); + expect(result.errors).toHaveLength(1); + expect(result.errors![0]).toMatchInlineSnapshot( + `[MachineTokenVerificationError: Invalid JWT audience claim (aud) "https://attacker.example.com". Is not included in "["https://my-resource.example.com"]".]`, + ); + }); }); describe('verifyM2MToken with JWT', () => { diff --git a/packages/shared/src/types/jwt.ts b/packages/shared/src/types/jwt.ts index f2b47790a75..67295ca1946 100644 --- a/packages/shared/src/types/jwt.ts +++ b/packages/shared/src/types/jwt.ts @@ -77,6 +77,11 @@ export interface ClerkJWTClaims { */ azp?: string; + /** + * JWT Audience - [RFC7519#section-4.1.3](https://tools.ietf.org/html/rfc7519#section-4.1.3). + */ + aud?: string | string[]; + /** * JWT Actor - [RFC8693](https://www.rfc-editor.org/rfc/rfc8693.html#name-act-actor-claim). */ diff --git a/packages/shared/src/types/jwtv2.ts b/packages/shared/src/types/jwtv2.ts index da7e1de736a..cb2e288d20a 100644 --- a/packages/shared/src/types/jwtv2.ts +++ b/packages/shared/src/types/jwtv2.ts @@ -82,6 +82,11 @@ type JWTPayloadBase = { */ azp?: string; + /** + * JWT Audience - [RFC7519#section-4.1.3](https://tools.ietf.org/html/rfc7519#section-4.1.3). + */ + aud?: string | string[]; + /** * JWT Actor - [RFC8693](https://www.rfc-editor.org/rfc/rfc8693.html#name-act-actor-claim). */