diff --git a/.changeset/type-jwt-aud-rfc-8707.md b/.changeset/type-jwt-aud-rfc-8707.md new file mode 100644 index 00000000000..50b1fcc4dd3 --- /dev/null +++ b/.changeset/type-jwt-aud-rfc-8707.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Expose the optional `aud` audience on verified OAuth access tokens. \ No newline at end of file diff --git a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts index 8399057e59b..0dff41b2b3d 100644 --- a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts +++ b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts @@ -3,6 +3,7 @@ import type { JwtPayload } from '@clerk/shared/types'; import type { IdPOAuthAccessTokenJSON } from './JSON'; type OAuthJwtPayload = JwtPayload & { + aud?: string | string[]; jti?: string; client_id?: string; scope?: string; @@ -25,6 +26,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 +43,7 @@ export class IdPOAuthAccessToken { data.expiration, data.created_at, data.updated_at, + data.aud, ); } @@ -63,6 +67,7 @@ export class IdPOAuthAccessToken { 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.aud, ); } } diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index e1ff98e2ee1..b731aa2cfb8 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[]; } export interface BillingPayerJSON extends ClerkResourceJSON { diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 6a12115c01a..01784d3200e 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, { @@ -230,14 +230,19 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); }); - it('verifies provided OAuth token', async () => { + it.each([ + { aud: undefined }, + { aud: [] }, + { aud: ['https://my-resource.example.com'] }, + { aud: ['https://my-resource.example.com', 'https://other-resource.example.com'] }, + ])('verifies opaque OAuth token with aud=$aud', async ({ aud }) => { const token = 'oat_8XOIucKvqHVr5tYP123456789abcdefghij'; server.use( http.post( 'https://api.clerk.test/oauth_applications/access_tokens/verify', validateHeaders(() => { - return HttpResponse.json(mockVerificationResults.oauth_token); + return HttpResponse.json({ ...mockVerificationResults.oauth_token, ...(aud === undefined ? {} : { aud }) }); }), ), ); @@ -255,6 +260,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.id).toBe('oat_2VTWUzvGC5UhdJCNx6xG1D98edc'); expect(data.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); expect(data.scopes).toEqual(['read:foo', 'write:bar']); + expect(data.aud).toEqual(aud); }); describe('handles API errors for API keys', () => { @@ -424,6 +430,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.type).toBe('oauth_token'); expect(data.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); expect(data.scopes).toEqual(['read:foo', 'write:bar']); + expect(data.aud).toBeUndefined(); // Timestamps are exposed in milliseconds, matching M2MToken and the API JSON shape expect(data.expiration).toBe(mockOAuthAccessTokenJwtPayload.exp * 1000); expect(data.createdAt).toBe(mockOAuthAccessTokenJwtPayload.iat * 1000); @@ -558,7 +565,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 +576,65 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.tokenType).toBe('oauth_token'); }, ); + + it.each([ + { aud: 'https://my-resource.example.com' }, + { aud: ['https://my-resource.example.com', 'https://other-resource.example.com'] }, + ])('verifies OAuth JWT with a matching resource audience aud=$aud', async ({ aud }) => { + 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, + }); + + const result = await verifyMachineAuthToken(oauthJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + audience, + }); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toMatchObject({ aud, scopes: ['read:foo', 'write:bar'] }); + 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', () => {