Skip to content
6 changes: 6 additions & 0 deletions .changeset/strict-oauth-audience.md
Original file line number Diff line number Diff line change
@@ -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()`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could we explicitly mention the upgrade impact here that when audience is configured, missing or malformed OAuth tokens are now rejected? Thanks!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@wobsoriano added additional context in d295eb8

65 changes: 65 additions & 0 deletions packages/backend/src/api/__tests__/IdPOAuthAccessTokenApi.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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"].',
);
});
});
26 changes: 24 additions & 2 deletions packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,37 @@
import { assertOAuthAudienceClaim } from '../../jwt/assertions';
import { joinPaths } from '../../util/path';
import type { IdPOAuthAccessToken } from '../resources';
import { AbstractAPI } from './AbstractApi';

const basePath = '/oauth_applications/access_tokens';

export class IdPOAuthAccessTokenApi extends AbstractAPI {
async verify(accessToken: string) {
return this.request<IdPOAuthAccessToken>({
/**
* 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<IdPOAuthAccessToken> {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const verifiedToken = await this.request<IdPOAuthAccessToken>({
method: 'POST',
path: joinPaths(basePath, 'verify'),
bodyParams: { access_token: accessToken },
});

assertOAuthAudienceClaim(verifiedToken.aud, options.audience);
return verifiedToken;
}
}
23 changes: 23 additions & 0 deletions packages/backend/src/jwt/__tests__/assertions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
assertHeaderAlgorithm,
assertHeaderType,
assertIssuedAtClaim,
assertOAuthAudienceClaim,
assertSubClaim,
} from '../assertions';

Expand Down Expand Up @@ -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();
Expand Down
24 changes: 24 additions & 0 deletions packages/backend/src/jwt/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {
Comment thread
thiskevinwang marked this conversation as resolved.
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;
Expand Down
30 changes: 29 additions & 1 deletion packages/backend/src/jwt/verifyMachineJwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoadClerkJWKFromRemoteOptions, 'secretKey' | 'apiUrl' | 'skipJwksCache'> & {
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.
*
Expand Down Expand Up @@ -125,12 +143,22 @@ export async function verifyOAuthJwt(
decoded: Jwt,
options: JwtMachineVerifyOptions,
): Promise<MachineTokenReturnType<IdPOAuthAccessToken, MachineTokenVerificationError>> {
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,
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 @@ -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 }) => {
Expand Down
Loading
Loading