From 03e6bb6504c93b150858baa66ccbc424b4b82e70 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Fri, 11 Sep 2026 10:54:52 -0400 Subject: [PATCH] fix(backend)!: require matching OAuth token audiences --- .changeset/strict-oauth-audience.md | 7 + packages/backend/src/jwt/assertions.ts | 17 +++ packages/backend/src/jwt/verifyMachineJwt.ts | 17 +++ .../src/tokens/__tests__/request.test.ts | 62 +++++++++ .../src/tokens/__tests__/verify.test.ts | 129 +++++++++++++++++- packages/backend/src/tokens/verify.ts | 14 ++ 6 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 .changeset/strict-oauth-audience.md diff --git a/.changeset/strict-oauth-audience.md b/.changeset/strict-oauth-audience.md new file mode 100644 index 00000000000..fa546081eea --- /dev/null +++ b/.changeset/strict-oauth-audience.md @@ -0,0 +1,7 @@ +--- +'@clerk/backend': major +--- + +OAuth access token verification now requires a matching `aud` when a non-empty `audience` option is configured, for both opaque tokens and JWTs. Tokens with a missing, empty, malformed, or mismatched audience are rejected. + +Applications that configure `audience` must issue OAuth tokens with a matching audience before upgrading. Session JWT and M2M token verification behavior is unchanged. diff --git a/packages/backend/src/jwt/assertions.ts b/packages/backend/src/jwt/assertions.ts index 8ab74096f7e..e686ff15977 100644 --- a/packages/backend/src/jwt/assertions.ts +++ b/packages/backend/src/jwt/assertions.ts @@ -47,6 +47,23 @@ export const assertAudienceClaim = (aud?: unknown, audience?: unknown) => { } }; +export const assertOAuthAudienceClaim = (aud?: unknown, audience?: string | string[]) => { + if (![audience].flat().some(a => !!a)) { + return; + } + + const hasAudience = + (typeof aud === 'string' && aud.length > 0) || (isArrayString(aud) && aud.every(a => a.length > 0)); + if (!hasAudience) { + throw new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenVerificationFailed, + message: `Invalid OAuth audience claim (aud) ${JSON.stringify(aud)}. Expected a non-empty string or a non-empty array of non-empty strings.`, + }); + } + + assertAudienceClaim(aud, audience); +}; + export const assertHeaderType = (typ?: unknown, allowedTypes?: string | string[]) => { if (typeof typ === 'undefined' && typeof allowedTypes === 'undefined') { return; diff --git a/packages/backend/src/jwt/verifyMachineJwt.ts b/packages/backend/src/jwt/verifyMachineJwt.ts index 847660e0593..a269fc666ec 100644 --- a/packages/backend/src/jwt/verifyMachineJwt.ts +++ b/packages/backend/src/jwt/verifyMachineJwt.ts @@ -14,8 +14,10 @@ import type { LoadClerkJWKFromRemoteOptions } from '../tokens/keys'; import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from '../tokens/keys'; import { OAUTH_ACCESS_TOKEN_TYPES } from '../tokens/machine'; import { TokenType } from '../tokens/tokenTypes'; +import { assertOAuthAudienceClaim } from './assertions'; export type JwtMachineVerifyOptions = Pick & { + audience?: string | string[]; jwtKey?: string; clockSkewInMs?: number; }; @@ -131,6 +133,21 @@ export async function verifyOAuthJwt( return { data: undefined, tokenType: TokenType.OAuthToken, errors: [result.error] }; } + try { + assertOAuthAudienceClaim(result.payload.aud, options.audience); + } catch (error) { + return { + data: undefined, + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: (error as Error).message, + }), + ], + }; + } + return { data: IdPOAuthAccessToken.fromJwtPayload(result.payload, options.clockSkewInMs), tokenType: TokenType.OAuthToken, diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index ff57ed9f50a..cfe98c5aeff 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1590,6 +1590,68 @@ describe('tokens.authenticateRequest(options)', () => { }); }); + test.each(['oauth_token', 'any'] as const)( + 'rejects an opaque OAuth audience mismatch when acceptsToken is %s', + async acceptsToken => { + server.use( + http.post(mockMachineAuthResponses.oauth_token.endpoint, () => { + return HttpResponse.json({ + ...mockVerificationResults.oauth_token, + aud: 'https://other.example.com', + }); + }), + ); + + const request = mockRequest({ authorization: `Bearer ${mockTokens.oauth_token}` }); + const requestState = await authenticateRequest( + request, + mockOptions({ acceptsToken, audience: 'https://resource.example.com' }), + ); + + expect(requestState).toBeMachineUnauthenticated({ + tokenType: 'oauth_token', + reason: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: + 'Invalid JWT audience claim (aud) "https://other.example.com". Is not included in "["https://resource.example.com"]". (code=token-verification-failed, status=n/a)', + }); + expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({ + tokenType: 'oauth_token', + isAuthenticated: false, + }); + }, + ); + + describe.each(['opaque', 'JWT'] as const)('%s OAuth token without aud', format => { + test.each(['oauth_token', 'any'] as const)( + 'rejects a configured audience when acceptsToken is %s', + async acceptsToken => { + server.use( + http.post(mockMachineAuthResponses.oauth_token.endpoint, () => { + return HttpResponse.json(mockVerificationResults.oauth_token); + }), + http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)), + ); + const token = format === 'opaque' ? mockTokens.oauth_token : mockSignedOAuthAccessTokenJwt; + const request = mockRequest({ authorization: `Bearer ${token}` }); + const requestState = await authenticateRequest( + request, + mockOptions({ acceptsToken, audience: 'https://resource.example.com' }), + ); + + expect(requestState).toBeMachineUnauthenticated({ + tokenType: 'oauth_token', + reason: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: + 'Invalid OAuth audience claim (aud) undefined. Expected a non-empty string or a non-empty array of non-empty strings. (code=token-verification-failed, status=n/a)', + }); + expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({ + tokenType: 'oauth_token', + isAuthenticated: false, + }); + }, + ); + }); + test('accepts machine secret when verifying machine-to-machine token', async () => { server.use( http.post(mockMachineAuthResponses.m2m_token.endpoint, ({ request }) => { diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 603b858a090..808986cab26 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -2,6 +2,7 @@ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { APIKey, IdPOAuthAccessToken, M2MToken } from '../../api'; +import { MachineTokenVerificationError, MachineTokenVerificationErrorCode } from '../../errors'; import { createJwt, mockJwks, @@ -32,7 +33,10 @@ async function createSignedOAuthJwt( return data!; } -async function createSignedM2MJwt(payload = mockM2MJwtPayload, cat: string | undefined = JWT_CATEGORY_M2M_TOKEN) { +async function createSignedM2MJwt( + payload: Record = mockM2MJwtPayload, + cat: string | undefined = JWT_CATEGORY_M2M_TOKEN, +) { const { data } = await signJwt(payload, signingJwks, { algorithm: 'RS256', header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', ...(cat !== undefined ? { cat } : {}) }, @@ -69,6 +73,19 @@ describe('tokens.verify(token, options)', () => { expect(data).toEqual(mockJwtPayload); }); + it('continues accepting session JWTs without aud when audience is configured', async () => { + server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); + + const result = await verifyToken(mockJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + audience: 'https://resource.example.com', + }); + + expect(result.data).toEqual(mockJwtPayload); + expect(result.errors).toBeUndefined(); + }); + it('verifies the token by fetching the JWKs from Backend API when secretKey is provided', async () => { server.use( http.get( @@ -257,6 +274,101 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.scopes).toEqual(['read:foo', 'write:bar']); }); + describe.each(['opaque', 'at+jwt', 'application/at+jwt'] as const)('%s OAuth token audience verification', format => { + const audience = 'https://resource.example.com'; + const otherAudience = 'https://other.example.com'; + + beforeEach(() => { + vi.setSystemTime(new Date(mockOAuthAccessTokenJwtPayload.iat * 1000)); + }); + + async function verifyWithAudience(aud: unknown, audience?: string | string[]) { + let token: string; + if (format === 'opaque') { + token = 'oat_8XOIucKvqHVr5tYP123456789abcdefghij'; + server.use( + http.post('https://api.clerk.test/oauth_applications/access_tokens/verify', () => { + return HttpResponse.json({ ...mockVerificationResults.oauth_token, aud }); + }), + ); + } else { + server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); + token = await createSignedOAuthJwt({ ...mockOAuthAccessTokenJwtPayload, aud }, format); + } + + return verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + audience, + }); + } + + it.each([ + { aud: audience, audience }, + { aud: audience, audience: [otherAudience, audience] }, + { aud: [otherAudience, audience], audience }, + { aud: [otherAudience, audience], audience: [audience] }, + { aud: undefined, audience: undefined }, + { aud: undefined, audience: '' }, + { aud: undefined, audience: [] }, + { aud: '', audience: undefined }, + { aud: [], audience: undefined }, + { aud: otherAudience, audience: undefined }, + { aud: otherAudience, audience: '' }, + { aud: otherAudience, audience: [] }, + ])('accepts aud=$aud with audience=$audience', async ({ aud, audience }) => { + const result = await verifyWithAudience(aud, audience); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toBeDefined(); + expect((result.data as IdPOAuthAccessToken).aud).toEqual(aud); + expect(result.errors).toBeUndefined(); + }); + + it.each([ + { aud: otherAudience, audience }, + { aud: otherAudience, audience: [audience] }, + { aud: [otherAudience], audience }, + { aud: [otherAudience], audience: [audience] }, + { aud: `${audience}/other`, audience }, + { aud: audience.toUpperCase(), audience }, + ])('rejects aud=$aud with audience=$audience', async ({ aud, audience }) => { + const result = await verifyWithAudience(aud, audience); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toBeUndefined(); + expect(result.errors).toHaveLength(1); + expect(result.errors![0]).toBeInstanceOf(MachineTokenVerificationError); + expect(result.errors![0]).toMatchObject({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: expect.stringContaining('Invalid JWT audience claim'), + }); + }); + + it.each([undefined, null, '', [], [''], [audience, ''], 42, true, {}, [audience, 42]].map(aud => ({ aud })))( + 'rejects missing, empty, or malformed aud=$aud when audience is configured', + async ({ aud }) => { + const result = await verifyWithAudience(aud, audience); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toBeUndefined(); + expect(result.errors).toHaveLength(1); + expect(result.errors![0]).toBeInstanceOf(MachineTokenVerificationError); + expect(result.errors![0]).toMatchObject({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: expect.stringContaining('Invalid OAuth audience claim'), + }); + }, + ); + + it('rejects missing aud when audience is an array', async () => { + const result = await verifyWithAudience(undefined, [audience]); + + expect(result.data).toBeUndefined(); + expect(result.errors?.[0].code).toBe(MachineTokenVerificationErrorCode.TokenVerificationFailed); + }); + }); + describe('handles API errors for API keys', () => { it('handles invalid token', async () => { const token = 'ak_invalid_token'; @@ -683,6 +795,21 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); }); + it('continues accepting M2M JWTs without aud when audience is configured', async () => { + server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); + const token = await createSignedM2MJwt({ ...mockM2MJwtPayload, aud: undefined }); + + const result = await verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + audience: 'https://resource.example.com', + }); + + expect(result.tokenType).toBe('m2m_token'); + expect(result.data).toBeDefined(); + expect(result.errors).toBeUndefined(); + }); + it('rejects M2M JWT with alg: none', async () => { server.use( http.get( diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index f4aab370fc1..f15d9de0906 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -11,6 +11,7 @@ import { TokenVerificationErrorReason, } from '../errors'; import type { VerifyJwtOptions } from '../jwt'; +import { assertOAuthAudienceClaim } from '../jwt/assertions'; import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types'; import { decodeJwt, verifyJwt } from '../jwt/verifyJwt'; import { verifyM2MJwt, verifyOAuthJwt } from '../jwt/verifyMachineJwt'; @@ -228,8 +229,21 @@ async function verifyOAuthToken( try { const client = createBackendApiClient(options); const verifiedToken = await client.idPOAuthAccessToken.verify(accessToken); + assertOAuthAudienceClaim(verifiedToken.aud, options.audience); return { data: verifiedToken, tokenType: TokenType.OAuthToken, errors: undefined }; } catch (err: any) { + if (err instanceof TokenVerificationError) { + return { + data: undefined, + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: err.message, + }), + ], + }; + } return handleClerkAPIError(TokenType.OAuthToken, err, 'OAuth token not found'); } }