diff --git a/.changeset/strict-oauth-audience.md b/.changeset/strict-oauth-audience.md new file mode 100644 index 00000000000..57daea8315c --- /dev/null +++ b/.changeset/strict-oauth-audience.md @@ -0,0 +1,6 @@ +--- +'@clerk/backend': minor +--- + +- Fixes an issue where OAuth token validation did not correctly validate audience (`aud`) claims. Previous usages that specified `audience` were falsely passing. This upgrade will cause those to start rejecting if the `audience` indeed does not match the OAuth token's `aud` claim, including cases where the `aud` claim is omitted. +- Adds an optional `audience` parameter to `idPOAuthAccessToken.verify()` diff --git a/packages/backend/src/api/__tests__/IdPOAuthAccessTokenApi.test.ts b/packages/backend/src/api/__tests__/IdPOAuthAccessTokenApi.test.ts new file mode 100644 index 00000000000..e6dfc86c2e0 --- /dev/null +++ b/packages/backend/src/api/__tests__/IdPOAuthAccessTokenApi.test.ts @@ -0,0 +1,65 @@ +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { server, validateHeaders } from '../../mock-server'; +import { createBackendApiClient } from '../factory'; + +describe('IdPOAuthAccessToken', () => { + const accessToken = 'oat_xxxxx'; + const audience = 'https://resource.example.com'; + const tokenResponse = { + object: 'clerk_idp_oauth_access_token', + id: accessToken, + client_id: 'client_xxxxx', + type: 'oauth:access_token', + subject: 'user_xxxxx', + scopes: ['read:foo'], + revoked: false, + revocation_reason: null, + expired: false, + expiration: null, + created_at: 1753743316590, + updated_at: 1753743316590, + }; + + it('verifies an opaque OAuth token with a matching audience', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_xxxxx', + }); + + server.use( + http.post( + 'https://api.clerk.test/oauth_applications/access_tokens/verify', + validateHeaders(async ({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer sk_xxxxx'); + const body = (await request.json()) as Record; + expect(body.access_token).toBe(accessToken); + return HttpResponse.json({ ...tokenResponse, aud: [audience] }); + }), + ), + ); + + const response = await apiClient.idPOAuthAccessToken.verify(accessToken, { audience }); + + expect(response.id).toBe(accessToken); + expect(response.aud).toEqual([audience]); + }); + + it('rejects an opaque OAuth token with a mismatched audience', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_xxxxx', + }); + + server.use( + http.post('https://api.clerk.test/oauth_applications/access_tokens/verify', () => + HttpResponse.json({ ...tokenResponse, aud: ['https://other.example.com'] }), + ), + ); + + await expect(apiClient.idPOAuthAccessToken.verify(accessToken, { audience })).rejects.toThrow( + 'OAuth audience mismatch. Verification expected audience ["https://resource.example.com"], but incoming token has aud ["https://other.example.com"].', + ); + }); +}); diff --git a/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts b/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts index fa84a926c85..f9da595ef3e 100644 --- a/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts +++ b/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts @@ -1,3 +1,4 @@ +import { assertOAuthAudienceClaim } from '../../jwt/assertions'; import { joinPaths } from '../../util/path'; import type { IdPOAuthAccessToken } from '../resources'; import { AbstractAPI } from './AbstractApi'; @@ -5,11 +6,32 @@ import { AbstractAPI } from './AbstractApi'; const basePath = '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/oauth_applications/access_tokens'; export class IdPOAuthAccessTokenApi extends AbstractAPI { - async verify(accessToken: string) { - return this.request({ + /** + * Verifies an OAuth access token with the Clerk Backend API. If an audience is provided, at least one expected audience must match the token's `aud` claim. Without an audience, the SDK does not check the `aud` claim. + * + * @param accessToken - The OAuth access token to verify. + * @param options - Optional verification options. `audience` can be one expected audience or an array of acceptable audiences. + * @returns The verified `IdPOAuthAccessToken` resource, including its audience when present. + * @throws `ClerkAPIResponseError` if the Backend API rejects the token or request. + * @throws `TokenVerificationError` if an expected audience is provided but the token has no `aud` claim or none of its audiences match. + * + * @example + * ### Verify a token for an API + * + * ```ts + * const token = await clerkClient.idPOAuthAccessToken.verify(accessToken, { + * audience: 'https://api.example.com', + * }); + * ``` + */ + async verify(accessToken: string, options: { audience?: string | string[] } = {}): Promise { + const verifiedToken = await this.request({ method: 'POST', path: joinPaths(basePath, 'verify'), bodyParams: { access_token: accessToken }, }); + + assertOAuthAudienceClaim(verifiedToken.aud, options.audience); + return verifiedToken; } } diff --git a/packages/backend/src/jwt/__tests__/assertions.test.ts b/packages/backend/src/jwt/__tests__/assertions.test.ts index c61f09bf9e2..06cda659840 100644 --- a/packages/backend/src/jwt/__tests__/assertions.test.ts +++ b/packages/backend/src/jwt/__tests__/assertions.test.ts @@ -8,6 +8,7 @@ import { assertHeaderAlgorithm, assertHeaderType, assertIssuedAtClaim, + assertOAuthAudienceClaim, assertSubClaim, } from '../assertions'; @@ -112,6 +113,28 @@ describe('assertAudienceClaim(audience?, aud?)', () => { }); }); +describe('assertOAuthAudienceClaim(aud, audience?)', () => { + const audience = 'https://resource.example.com'; + const otherAudience = 'https://other.example.com'; + + it.each([ + { aud: audience, expected: audience }, + { aud: [otherAudience, audience], expected: audience }, + { aud: audience, expected: [otherAudience, audience] }, + { aud: [otherAudience, audience], expected: [audience] }, + ])('accepts aud=$aud with expected audience=$expected', ({ aud, expected }) => { + expect(() => assertOAuthAudienceClaim(aud, expected)).not.toThrow(); + }); + + it.each([undefined, null, '', []])('rejects missing or empty aud=%j when an audience is expected', aud => { + expect(() => assertOAuthAudienceClaim(aud, audience)).toThrow('Invalid OAuth audience claim'); + }); + + it.each([undefined, '', []])('skips audience validation when expected audience=%j', expected => { + expect(() => assertOAuthAudienceClaim(undefined, expected)).not.toThrow(); + }); +}); + describe('assertHeaderType(typ?, allowedTypes?)', () => { it('does not throw error if type is missing and allowed types are not configured', () => { expect(() => assertHeaderType(undefined)).not.toThrow(); diff --git a/packages/backend/src/jwt/assertions.ts b/packages/backend/src/jwt/assertions.ts index 8ab74096f7e..7ed3d440dcb 100644 --- a/packages/backend/src/jwt/assertions.ts +++ b/packages/backend/src/jwt/assertions.ts @@ -47,6 +47,30 @@ export const assertAudienceClaim = (aud?: unknown, audience?: unknown) => { } }; +export const assertOAuthAudienceClaim = (aud: unknown, audience?: string | string[]) => { + const audienceList = [audience].flat().filter(a => !!a); + if (audienceList.length === 0) { + return; + } + + const audFromToken = aud ? [aud].flat() : undefined; + if (!isArrayString(audFromToken) || audFromToken.some(a => a.length === 0)) { + 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.`, + }); + } + + if (!audFromToken.some(a => audienceList.includes(a))) { + throw new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenVerificationFailed, + message: `OAuth audience mismatch. Verification expected audience ${JSON.stringify( + audienceList, + )}, but incoming token has aud ${JSON.stringify(aud)}.`, + }); + } +}; + 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..4cbd2ca153d 100644 --- a/packages/backend/src/jwt/verifyMachineJwt.ts +++ b/packages/backend/src/jwt/verifyMachineJwt.ts @@ -14,12 +14,30 @@ 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; }; +export function getOAuthAudienceVerificationError( + aud: unknown, + audience?: string | string[], +): MachineTokenVerificationError | undefined { + try { + assertOAuthAudienceClaim(aud, audience); + } catch (error) { + return new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: (error as Error).message, + }); + } + + return undefined; +} + /** * Resolves the signing key and verifies a machine JWT's signature and claims. * @@ -125,12 +143,22 @@ export async function verifyOAuthJwt( decoded: Jwt, options: JwtMachineVerifyOptions, ): Promise> { - const result = await resolveKeyAndVerifyJwt(token, decoded.header.kid, options, OAUTH_ACCESS_TOKEN_TYPES); + const { audience, ...jwtOptions } = options; + const result = await resolveKeyAndVerifyJwt(token, decoded.header.kid, jwtOptions, OAUTH_ACCESS_TOKEN_TYPES); if ('error' in result) { return { data: undefined, tokenType: TokenType.OAuthToken, errors: [result.error] }; } + const audienceError = getOAuthAudienceVerificationError(result.payload.aud, audience); + if (audienceError) { + return { + data: undefined, + tokenType: TokenType.OAuthToken, + errors: [audienceError], + }; + } + 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 852e4ece880..27ab1a55047 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1598,6 +1598,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: + 'OAuth audience mismatch. Verification expected audience ["https://resource.example.com"], but incoming token has aud "https://other.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 a50499d9fef..5cbc3628071 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( @@ -263,6 +280,100 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.aud).toEqual(aud); }); + 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.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('OAuth audience mismatch'), + }); + }); + + 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'; @@ -635,7 +746,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { 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"]".]`, + `[MachineTokenVerificationError: OAuth audience mismatch. Verification expected audience ["https://my-resource.example.com"], but incoming token has aud "https://attacker.example.com".]`, ); }); }); @@ -677,6 +788,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 3921b6ca04c..b20387f93d4 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -227,9 +227,21 @@ async function verifyOAuthToken( ): Promise> { try { const client = createBackendApiClient(options); - const verifiedToken = await client.idPOAuthAccessToken.verify(accessToken); + const verifiedToken = await client.idPOAuthAccessToken.verify(accessToken, { audience: 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'); } }