Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/type-jwt-aud-rfc-8707.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': minor
---

Expose the optional `aud` audience on verified OAuth access tokens.
5 changes: 5 additions & 0 deletions packages/backend/src/api/resources/IdPOAuthAccessToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {}

static fromJSON(data: IdPOAuthAccessTokenJSON) {
Expand All @@ -40,6 +43,7 @@ export class IdPOAuthAccessToken {
data.expiration,
data.created_at,
data.updated_at,
data.aud,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] The opaque oat_ path surfaces aud but never compares it, so the audience option is silently a no-op for the default token format

This line makes aud available on the opaque-token result, but nothing on that path checks it. verifyMachineAuthToken routes oat_ tokens to verifyOAuthToken, which returns whatever client.idPOAuthAccessToken.verify() gave back and never reads options.audience; IdPOAuthAccessTokenApi.verify() sends only access_token, so the server cannot enforce it either. Grepping audience across packages/backend/src shows assertAudienceClaim in verifyJwt is the only runtime consumer, which is why the JWT path in this PR rejects a mismatched resource audience and the opaque path does not.

A resource server B that calls authenticateRequest(req, { acceptsToken: 'oauth_token', audience: 'https://b.example.com' }) will accept an opaque token minted for resource server A and hand back machineData.aud === ['https://a.example.com'] as if it had been validated. That is the confused-deputy case RFC 8707 exists to prevent, and whether it bites depends only on which token format the instance happens to emit. The new opaque-token test asserts data.aud round-trips without ever passing audience, which makes the field read as checked when it is only echoed.

Running the returned aud through assertAudienceClaim in verifyOAuthToken when options.audience is set would put both formats back on the same control.

— Comment generated 🤖 with @dominic-clerk's supervision (ai-security-code-review)

);
}

Expand All @@ -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,
);
}
}
1 change: 1 addition & 0 deletions packages/backend/src/api/resources/JSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
74 changes: 70 additions & 4 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { JWT_CATEGORY_M2M_TOKEN } from '../jwtCategories';
import { verifyMachineAuthToken, verifyToken } from '../verify';

async function createSignedOAuthJwt(
payload = mockOAuthAccessTokenJwtPayload,
payload: Record<string, unknown> = mockOAuthAccessTokenJwtPayload,
typ: 'at+jwt' | 'application/at+jwt' | 'JWT' = 'at+jwt',
) {
const { data } = await signJwt(payload, signingJwks, {
Expand Down Expand Up @@ -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 }) });
}),
),
);
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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',
Expand All @@ -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', () => {
Expand Down
Loading