Skip to content

Commit 5d0ecda

Browse files
committed
fix(backend)!: require matching OAuth token audiences
1 parent a03fa0b commit 5d0ecda

6 files changed

Lines changed: 245 additions & 1 deletion

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/backend': major
3+
---
4+
5+
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.
6+
7+
Applications that configure `audience` must issue OAuth tokens with a matching audience before upgrading. Session JWT and M2M token verification behavior is unchanged.

‎packages/backend/src/jwt/assertions.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,23 @@ export const assertAudienceClaim = (aud?: unknown, audience?: unknown) => {
4747
}
4848
};
4949

50+
export const assertOAuthAudienceClaim = (aud?: unknown, audience?: string | string[]) => {
51+
if (![audience].flat().some(a => !!a)) {
52+
return;
53+
}
54+
55+
const hasAudience =
56+
(typeof aud === 'string' && aud.length > 0) || (isArrayString(aud) && aud.every(a => a.length > 0));
57+
if (!hasAudience) {
58+
throw new TokenVerificationError({
59+
reason: TokenVerificationErrorReason.TokenVerificationFailed,
60+
message: `Invalid OAuth audience claim (aud) ${JSON.stringify(aud)}. Expected a non-empty string or a non-empty array of non-empty strings.`,
61+
});
62+
}
63+
64+
assertAudienceClaim(aud, audience);
65+
};
66+
5067
export const assertHeaderType = (typ?: unknown, allowedTypes?: string | string[]) => {
5168
if (typeof typ === 'undefined' && typeof allowedTypes === 'undefined') {
5269
return;

‎packages/backend/src/jwt/verifyMachineJwt.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import type { LoadClerkJWKFromRemoteOptions } from '../tokens/keys';
1414
import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from '../tokens/keys';
1515
import { OAUTH_ACCESS_TOKEN_TYPES } from '../tokens/machine';
1616
import { TokenType } from '../tokens/tokenTypes';
17+
import { assertOAuthAudienceClaim } from './assertions';
1718

1819
export type JwtMachineVerifyOptions = Pick<LoadClerkJWKFromRemoteOptions, 'secretKey' | 'apiUrl' | 'skipJwksCache'> & {
20+
audience?: string | string[];
1921
jwtKey?: string;
2022
clockSkewInMs?: number;
2123
};
@@ -131,6 +133,21 @@ export async function verifyOAuthJwt(
131133
return { data: undefined, tokenType: TokenType.OAuthToken, errors: [result.error] };
132134
}
133135

136+
try {
137+
assertOAuthAudienceClaim(result.payload.aud, options.audience);
138+
} catch (error) {
139+
return {
140+
data: undefined,
141+
tokenType: TokenType.OAuthToken,
142+
errors: [
143+
new MachineTokenVerificationError({
144+
code: MachineTokenVerificationErrorCode.TokenVerificationFailed,
145+
message: (error as Error).message,
146+
}),
147+
],
148+
};
149+
}
150+
134151
return {
135152
data: IdPOAuthAccessToken.fromJwtPayload(result.payload, options.clockSkewInMs),
136153
tokenType: TokenType.OAuthToken,

‎packages/backend/src/tokens/__tests__/request.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,6 +1598,68 @@ describe('tokens.authenticateRequest(options)', () => {
15981598
});
15991599
});
16001600

1601+
test.each(['oauth_token', 'any'] as const)(
1602+
'rejects an opaque OAuth audience mismatch when acceptsToken is %s',
1603+
async acceptsToken => {
1604+
server.use(
1605+
http.post(mockMachineAuthResponses.oauth_token.endpoint, () => {
1606+
return HttpResponse.json({
1607+
...mockVerificationResults.oauth_token,
1608+
aud: 'https://other.example.com',
1609+
});
1610+
}),
1611+
);
1612+
1613+
const request = mockRequest({ authorization: `Bearer ${mockTokens.oauth_token}` });
1614+
const requestState = await authenticateRequest(
1615+
request,
1616+
mockOptions({ acceptsToken, audience: 'https://resource.example.com' }),
1617+
);
1618+
1619+
expect(requestState).toBeMachineUnauthenticated({
1620+
tokenType: 'oauth_token',
1621+
reason: MachineTokenVerificationErrorCode.TokenVerificationFailed,
1622+
message:
1623+
'Invalid JWT audience claim (aud) "https://other.example.com". Is not included in "["https://resource.example.com"]". (code=token-verification-failed, status=n/a)',
1624+
});
1625+
expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({
1626+
tokenType: 'oauth_token',
1627+
isAuthenticated: false,
1628+
});
1629+
},
1630+
);
1631+
1632+
describe.each(['opaque', 'JWT'] as const)('%s OAuth token without aud', format => {
1633+
test.each(['oauth_token', 'any'] as const)(
1634+
'rejects a configured audience when acceptsToken is %s',
1635+
async acceptsToken => {
1636+
server.use(
1637+
http.post(mockMachineAuthResponses.oauth_token.endpoint, () => {
1638+
return HttpResponse.json(mockVerificationResults.oauth_token);
1639+
}),
1640+
http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)),
1641+
);
1642+
const token = format === 'opaque' ? mockTokens.oauth_token : mockSignedOAuthAccessTokenJwt;
1643+
const request = mockRequest({ authorization: `Bearer ${token}` });
1644+
const requestState = await authenticateRequest(
1645+
request,
1646+
mockOptions({ acceptsToken, audience: 'https://resource.example.com' }),
1647+
);
1648+
1649+
expect(requestState).toBeMachineUnauthenticated({
1650+
tokenType: 'oauth_token',
1651+
reason: MachineTokenVerificationErrorCode.TokenVerificationFailed,
1652+
message:
1653+
'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)',
1654+
});
1655+
expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({
1656+
tokenType: 'oauth_token',
1657+
isAuthenticated: false,
1658+
});
1659+
},
1660+
);
1661+
});
1662+
16011663
test('accepts machine secret when verifying machine-to-machine token', async () => {
16021664
server.use(
16031665
http.post(mockMachineAuthResponses.m2m_token.endpoint, ({ request }) => {

‎packages/backend/src/tokens/__tests__/verify.test.ts‎

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { http, HttpResponse } from 'msw';
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
33

44
import type { APIKey, IdPOAuthAccessToken, M2MToken } from '../../api';
5+
import { MachineTokenVerificationError, MachineTokenVerificationErrorCode } from '../../errors';
56
import {
67
createJwt,
78
mockJwks,
@@ -32,7 +33,10 @@ async function createSignedOAuthJwt(
3233
return data!;
3334
}
3435

35-
async function createSignedM2MJwt(payload = mockM2MJwtPayload, cat: string | undefined = JWT_CATEGORY_M2M_TOKEN) {
36+
async function createSignedM2MJwt(
37+
payload: Record<string, unknown> = mockM2MJwtPayload,
38+
cat: string | undefined = JWT_CATEGORY_M2M_TOKEN,
39+
) {
3640
const { data } = await signJwt(payload, signingJwks, {
3741
algorithm: 'RS256',
3842
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', ...(cat !== undefined ? { cat } : {}) },
@@ -69,6 +73,19 @@ describe('tokens.verify(token, options)', () => {
6973
expect(data).toEqual(mockJwtPayload);
7074
});
7175

76+
it('continues accepting session JWTs without aud when audience is configured', async () => {
77+
server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)));
78+
79+
const result = await verifyToken(mockJwt, {
80+
apiUrl: 'https://api.clerk.test',
81+
secretKey: 'a-valid-key',
82+
audience: 'https://resource.example.com',
83+
});
84+
85+
expect(result.data).toEqual(mockJwtPayload);
86+
expect(result.errors).toBeUndefined();
87+
});
88+
7289
it('verifies the token by fetching the JWKs from Backend API when secretKey is provided', async () => {
7390
server.use(
7491
http.get(
@@ -263,6 +280,101 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
263280
expect(data.aud).toEqual(aud);
264281
});
265282

283+
describe.each(['opaque', 'at+jwt', 'application/at+jwt'] as const)('%s OAuth token audience verification', format => {
284+
const audience = 'https://resource.example.com';
285+
const otherAudience = 'https://other.example.com';
286+
287+
beforeEach(() => {
288+
vi.setSystemTime(new Date(mockOAuthAccessTokenJwtPayload.iat * 1000));
289+
});
290+
291+
async function verifyWithAudience(aud: unknown, audience?: string | string[]) {
292+
let token: string;
293+
if (format === 'opaque') {
294+
token = 'oat_8XOIucKvqHVr5tYP123456789abcdefghij';
295+
server.use(
296+
http.post('https://api.clerk.test/oauth_applications/access_tokens/verify', () => {
297+
return HttpResponse.json({ ...mockVerificationResults.oauth_token, aud });
298+
}),
299+
);
300+
} else {
301+
server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)));
302+
token = await createSignedOAuthJwt({ ...mockOAuthAccessTokenJwtPayload, aud }, format);
303+
}
304+
305+
return verifyMachineAuthToken(token, {
306+
apiUrl: 'https://api.clerk.test',
307+
secretKey: 'a-valid-key',
308+
audience,
309+
});
310+
}
311+
312+
it.each([
313+
{ aud: audience, audience },
314+
{ aud: audience, audience: [otherAudience, audience] },
315+
{ aud: [otherAudience, audience], audience },
316+
{ aud: [otherAudience, audience], audience: [audience] },
317+
{ aud: undefined, audience: undefined },
318+
{ aud: undefined, audience: '' },
319+
{ aud: undefined, audience: [] },
320+
{ aud: '', audience: undefined },
321+
{ aud: [], audience: undefined },
322+
{ aud: otherAudience, audience: undefined },
323+
{ aud: otherAudience, audience: '' },
324+
{ aud: otherAudience, audience: [] },
325+
])('accepts aud=$aud with audience=$audience', async ({ aud, audience }) => {
326+
const result = await verifyWithAudience(aud, audience);
327+
328+
expect(result.tokenType).toBe('oauth_token');
329+
expect(result.data).toBeDefined();
330+
expect((result.data as IdPOAuthAccessToken).aud).toEqual(aud);
331+
expect(result.errors).toBeUndefined();
332+
});
333+
334+
it.each([
335+
{ aud: otherAudience, audience },
336+
{ aud: otherAudience, audience: [audience] },
337+
{ aud: [otherAudience], audience },
338+
{ aud: [otherAudience], audience: [audience] },
339+
{ aud: `${audience}/other`, audience },
340+
{ aud: audience.toUpperCase(), audience },
341+
])('rejects aud=$aud with audience=$audience', async ({ aud, audience }) => {
342+
const result = await verifyWithAudience(aud, audience);
343+
344+
expect(result.tokenType).toBe('oauth_token');
345+
expect(result.data).toBeUndefined();
346+
expect(result.errors).toHaveLength(1);
347+
expect(result.errors![0]).toBeInstanceOf(MachineTokenVerificationError);
348+
expect(result.errors![0]).toMatchObject({
349+
code: MachineTokenVerificationErrorCode.TokenVerificationFailed,
350+
message: expect.stringContaining('Invalid JWT audience claim'),
351+
});
352+
});
353+
354+
it.each([undefined, null, '', [], [''], [audience, ''], 42, true, {}, [audience, 42]].map(aud => ({ aud })))(
355+
'rejects missing, empty, or malformed aud=$aud when audience is configured',
356+
async ({ aud }) => {
357+
const result = await verifyWithAudience(aud, audience);
358+
359+
expect(result.tokenType).toBe('oauth_token');
360+
expect(result.data).toBeUndefined();
361+
expect(result.errors).toHaveLength(1);
362+
expect(result.errors![0]).toBeInstanceOf(MachineTokenVerificationError);
363+
expect(result.errors![0]).toMatchObject({
364+
code: MachineTokenVerificationErrorCode.TokenVerificationFailed,
365+
message: expect.stringContaining('Invalid OAuth audience claim'),
366+
});
367+
},
368+
);
369+
370+
it('rejects missing aud when audience is an array', async () => {
371+
const result = await verifyWithAudience(undefined, [audience]);
372+
373+
expect(result.data).toBeUndefined();
374+
expect(result.errors?.[0].code).toBe(MachineTokenVerificationErrorCode.TokenVerificationFailed);
375+
});
376+
});
377+
266378
describe('handles API errors for API keys', () => {
267379
it('handles invalid token', async () => {
268380
const token = 'ak_invalid_token';
@@ -677,6 +789,21 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
677789
expect(data.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
678790
});
679791

792+
it('continues accepting M2M JWTs without aud when audience is configured', async () => {
793+
server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)));
794+
const token = await createSignedM2MJwt({ ...mockM2MJwtPayload, aud: undefined });
795+
796+
const result = await verifyMachineAuthToken(token, {
797+
apiUrl: 'https://api.clerk.test',
798+
secretKey: 'a-valid-key',
799+
audience: 'https://resource.example.com',
800+
});
801+
802+
expect(result.tokenType).toBe('m2m_token');
803+
expect(result.data).toBeDefined();
804+
expect(result.errors).toBeUndefined();
805+
});
806+
680807
it('rejects M2M JWT with alg: none', async () => {
681808
server.use(
682809
http.get(

‎packages/backend/src/tokens/verify.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
TokenVerificationErrorReason,
1212
} from '../errors';
1313
import type { VerifyJwtOptions } from '../jwt';
14+
import { assertOAuthAudienceClaim } from '../jwt/assertions';
1415
import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types';
1516
import { decodeJwt, verifyJwt } from '../jwt/verifyJwt';
1617
import { verifyM2MJwt, verifyOAuthJwt } from '../jwt/verifyMachineJwt';
@@ -228,8 +229,21 @@ async function verifyOAuthToken(
228229
try {
229230
const client = createBackendApiClient(options);
230231
const verifiedToken = await client.idPOAuthAccessToken.verify(accessToken);
232+
assertOAuthAudienceClaim(verifiedToken.aud, options.audience);
231233
return { data: verifiedToken, tokenType: TokenType.OAuthToken, errors: undefined };
232234
} catch (err: any) {
235+
if (err instanceof TokenVerificationError) {
236+
return {
237+
data: undefined,
238+
tokenType: TokenType.OAuthToken,
239+
errors: [
240+
new MachineTokenVerificationError({
241+
code: MachineTokenVerificationErrorCode.TokenVerificationFailed,
242+
message: err.message,
243+
}),
244+
],
245+
};
246+
}
233247
return handleClerkAPIError(TokenType.OAuthToken, err, 'OAuth token not found');
234248
}
235249
}

0 commit comments

Comments
 (0)