Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/strict-oauth-audience.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions packages/backend/src/jwt/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions packages/backend/src/jwt/verifyMachineJwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoadClerkJWKFromRemoteOptions, 'secretKey' | 'apiUrl' | 'skipJwksCache'> & {
audience?: string | string[];
jwtKey?: string;
clockSkewInMs?: number;
};
Expand Down Expand Up @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
129 changes: 128 additions & 1 deletion packages/backend/src/tokens/__tests__/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> = 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 } : {}) },
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions packages/backend/src/tokens/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
}
}
Expand Down
Loading