diff --git a/.changeset/backend-oauth-token-audience.md b/.changeset/backend-oauth-token-audience.md new file mode 100644 index 00000000000..df72c225d1e --- /dev/null +++ b/.changeset/backend-oauth-token-audience.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Enforce audience binding for OAuth access tokens. When `audience` is configured, `authenticateRequest()` with `acceptsToken: 'oauth_token'` (and `verifyMachineAuthToken()`) now returns an unauthenticated state for JWT and opaque tokens whose `aud` is missing or does not include the configured audience. The OAuth auth object additionally exposes the token's `aud` and `act` claims alongside `scopes` and `clientId`. OAuth JWTs can now be verified without a secret key: when only a `publishableKey` is configured, the JWKS is loaded from the Frontend API. diff --git a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts index 8399057e59b..393fbef94f0 100644 --- a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts +++ b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts @@ -1,4 +1,4 @@ -import type { JwtPayload } from '@clerk/shared/types'; +import type { ActClaim, JwtPayload } from '@clerk/shared/types'; import type { IdPOAuthAccessTokenJSON } from './JSON'; @@ -9,6 +9,10 @@ type OAuthJwtPayload = JwtPayload & { scp?: string[]; }; +function toAudienceList(aud: unknown): string[] { + return [aud].flat().filter((a): a is string => typeof a === 'string' && a.length > 0); +} + export class IdPOAuthAccessToken { constructor( readonly id: string, @@ -25,6 +29,8 @@ export class IdPOAuthAccessToken { readonly createdAt: number, /** The Unix timestamp (in milliseconds) when the access token was last updated. */ readonly updatedAt: number, + readonly aud: string[] = [], + readonly act: ActClaim | null = null, ) {} static fromJSON(data: IdPOAuthAccessTokenJSON) { @@ -40,6 +46,8 @@ export class IdPOAuthAccessToken { data.expiration, data.created_at, data.updated_at, + data.aud ?? [], + data.act ?? null, ); } @@ -63,6 +71,8 @@ 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 + toAudienceList(payload.aud), + payload.act ?? null, ); } } diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 4c486b2a202..58e7914736f 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -1,4 +1,5 @@ import type { + ActClaim, BillingPerUnitTotalJSON, BillingSubscriptionItemNextPaymentJSON, BillingSubscriptionItemSeatsJSON, @@ -957,6 +958,8 @@ export interface IdPOAuthAccessTokenJSON extends ClerkResourceJSON { type: string; subject: string; scopes: string[]; + aud?: string[]; + act?: ActClaim | null; revoked: boolean; revocation_reason: string | null; expired: boolean; diff --git a/packages/backend/src/api/resources/__tests__/IdPOAuthAccessToken.test.ts b/packages/backend/src/api/resources/__tests__/IdPOAuthAccessToken.test.ts new file mode 100644 index 00000000000..717b737fd3a --- /dev/null +++ b/packages/backend/src/api/resources/__tests__/IdPOAuthAccessToken.test.ts @@ -0,0 +1,102 @@ +import type { JwtPayload } from '@clerk/shared/types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { IdPOAuthAccessToken } from '../IdPOAuthAccessToken'; +import type { IdPOAuthAccessTokenJSON } from '../JSON'; + +const basePayload = { + iss: 'https://clerk.oauth.example.test', + sub: 'user_2vYVtestTESTtestTESTtestTESTtest', + client_id: 'client_2VTWUzvGC5UhdJCNx6xG1D98edc', + scope: 'read:foo write:bar', + jti: 'oat_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE', + exp: 1666648550, + iat: 1666648250, + nbf: 1666648240, +}; + +const asPayload = (claims: Record) => ({ ...basePayload, ...claims }) as unknown as JwtPayload; + +const baseJSON: IdPOAuthAccessTokenJSON = { + object: 'clerk_idp_oauth_access_token', + id: 'oat_2VTWUzvGC5UhdJCNx6xG1D98edc', + client_id: 'client_2VTWUzvGC5UhdJCNx6xG1D98edc', + type: 'oauth:access_token', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + scopes: ['read:foo', 'write:bar'], + revoked: false, + revocation_reason: null, + expired: false, + expiration: null, + created_at: 1744928754551, + updated_at: 1744928754551, +}; + +describe('IdPOAuthAccessToken', () => { + describe('fromJwtPayload', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(basePayload.iat * 1000)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('maps the OAuth claims', () => { + const token = IdPOAuthAccessToken.fromJwtPayload(asPayload({})); + + expect(token.id).toBe('oat_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE'); + expect(token.clientId).toBe('client_2VTWUzvGC5UhdJCNx6xG1D98edc'); + expect(token.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); + expect(token.scopes).toEqual(['read:foo', 'write:bar']); + expect(token.aud).toEqual([]); + expect(token.act).toBeNull(); + }); + + it('normalizes a string aud claim to a list', () => { + const token = IdPOAuthAccessToken.fromJwtPayload(asPayload({ aud: 'https://mcp.example.test/mcp' })); + + expect(token.aud).toEqual(['https://mcp.example.test/mcp']); + }); + + it('keeps an array aud claim', () => { + const aud = ['https://mcp.example.test/mcp', 'https://api.example.test']; + const token = IdPOAuthAccessToken.fromJwtPayload(asPayload({ aud })); + + expect(token.aud).toEqual(aud); + }); + + it('drops non-string aud entries', () => { + const token = IdPOAuthAccessToken.fromJwtPayload( + asPayload({ aud: ['https://mcp.example.test/mcp', 42, '', null] }), + ); + + expect(token.aud).toEqual(['https://mcp.example.test/mcp']); + }); + + it('exposes the act claim', () => { + const act = { sub: 'client_2agentTESTtestTESTtestTESTtest' }; + const token = IdPOAuthAccessToken.fromJwtPayload(asPayload({ act })); + + expect(token.act).toEqual(act); + }); + }); + + describe('fromJSON', () => { + it('defaults aud and act when the API omits them', () => { + const token = IdPOAuthAccessToken.fromJSON(baseJSON); + + expect(token.aud).toEqual([]); + expect(token.act).toBeNull(); + }); + + it('maps aud and act from the API response', () => { + const act = { sub: 'client_2agentTESTtestTESTtestTESTtest' }; + const token = IdPOAuthAccessToken.fromJSON({ ...baseJSON, aud: ['https://mcp.example.test/mcp'], act }); + + expect(token.aud).toEqual(['https://mcp.example.test/mcp']); + expect(token.act).toEqual(act); + }); + }); +}); diff --git a/packages/backend/src/fixtures/machine.ts b/packages/backend/src/fixtures/machine.ts index 5b2f3cb5134..787c291e152 100644 --- a/packages/backend/src/fixtures/machine.ts +++ b/packages/backend/src/fixtures/machine.ts @@ -29,6 +29,8 @@ export const mockVerificationResults = { name: 'GitHub OAuth', subject: 'user_2vYVtestTESTtestTESTtestTESTtest', scopes: ['read:foo', 'write:bar'], + aud: ['https://mcp.example.test/mcp'], + act: null, revoked: false, revocationReason: null, expired: false, diff --git a/packages/backend/src/jwt/verifyMachineJwt.ts b/packages/backend/src/jwt/verifyMachineJwt.ts index 847660e0593..8052fd32e9a 100644 --- a/packages/backend/src/jwt/verifyMachineJwt.ts +++ b/packages/backend/src/jwt/verifyMachineJwt.ts @@ -8,6 +8,7 @@ import { TokenVerificationErrorAction, } from '../errors'; import type { MachineTokenReturnType } from '../jwt/types'; +import type { VerifyJwtOptions } from '../jwt/verifyJwt'; import { verifyJwt } from '../jwt/verifyJwt'; import { JWT_CATEGORY_M2M_TOKEN } from '../tokens/jwtCategories'; import type { LoadClerkJWKFromRemoteOptions } from '../tokens/keys'; @@ -15,15 +16,20 @@ import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from '../tokens/keys'; import { OAUTH_ACCESS_TOKEN_TYPES } from '../tokens/machine'; import { TokenType } from '../tokens/tokenTypes'; -export type JwtMachineVerifyOptions = Pick & { +export type JwtMachineVerifyOptions = Pick< + LoadClerkJWKFromRemoteOptions, + 'secretKey' | 'publishableKey' | 'apiUrl' | 'skipJwksCache' +> & { jwtKey?: string; clockSkewInMs?: number; + audience?: VerifyJwtOptions['audience']; }; /** * Resolves the signing key and verifies a machine JWT's signature and claims. * - * Networkless when `jwtKey` (PEM) is provided; performs a JWKS fetch when only `secretKey` is set. + * Networkless when `jwtKey` (PEM) is provided; otherwise fetches the JWKS from the Backend API + * (`secretKey`) or the Frontend API (`publishableKey`). * Returns a discriminated union so callers can branch on `'error' in result` without try/catch. * * Note: uses `MachineTokenVerificationError`, not `TokenVerificationError` — the two error types @@ -41,7 +47,7 @@ async function resolveKeyAndVerifyJwt( if (options.jwtKey) { key = loadClerkJwkFromPem({ kid, pem: options.jwtKey }); - } else if (options.secretKey) { + } else if (options.secretKey || options.publishableKey) { key = await loadClerkJWKFromRemote({ ...options, kid }); } else { return { @@ -125,15 +131,33 @@ 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] }; } - return { - data: IdPOAuthAccessToken.fromJwtPayload(result.payload, options.clockSkewInMs), - tokenType: TokenType.OAuthToken, - errors: undefined, - }; + const data = IdPOAuthAccessToken.fromJwtPayload(result.payload, options.clockSkewInMs); + const audienceError = verifyOAuthAudience(data.aud, audience); + if (audienceError) { + return { data: undefined, tokenType: TokenType.OAuthToken, errors: [audienceError] }; + } + + return { data, tokenType: TokenType.OAuthToken, errors: undefined }; +} + +export function verifyOAuthAudience( + aud: string[], + audience: VerifyJwtOptions['audience'], +): MachineTokenVerificationError | undefined { + const expected = [audience].flat().filter((a): a is string => !!a); + if (expected.length === 0 || aud.some(a => expected.includes(a))) { + return undefined; + } + + return new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: `Invalid OAuth access token audience (aud) ${JSON.stringify(aud)}. Expected one of ${JSON.stringify(expected)}.`, + }); } diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index fde4ec53c75..dcb41cc74f0 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -371,6 +371,14 @@ describe('authenticatedMachineObject', () => { expect(authObject.scopes).toEqual(['read:foo', 'write:bar']); expect(authObject.userId).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); expect(authObject.clientId).toBe('client_2VTWUzvGC5UhdJCNx6xG1D98edc'); + expect(authObject.aud).toEqual(['https://mcp.example.test/mcp']); + expect(authObject.act).toBeNull(); + }); + + it('exposes the actor claim', () => { + const act = { sub: 'client_2agentTESTtestTESTtestTESTtest' }; + const authObject = authenticatedMachineObject('oauth_token', token, { ...verificationResult, act }, debugData); + expect(authObject.act).toEqual(act); }); }); @@ -410,6 +418,14 @@ describe('unauthenticatedMachineObject', () => { expect(authObject.scopes).toBeNull(); }); + it('nulls the OAuth token properties', () => { + const authObject = unauthenticatedMachineObject('oauth_token'); + expect(authObject.userId).toBeNull(); + expect(authObject.clientId).toBeNull(); + expect(authObject.aud).toBeNull(); + expect(authObject.act).toBeNull(); + }); + it('has() always returns false', () => { const authObject = unauthenticatedMachineObject('m2m_token'); expect(authObject.has({})).toBe(false); diff --git a/packages/backend/src/tokens/__tests__/keys.test.ts b/packages/backend/src/tokens/__tests__/keys.test.ts index 110273e0613..d6a3918281a 100644 --- a/packages/backend/src/tokens/__tests__/keys.test.ts +++ b/packages/backend/src/tokens/__tests__/keys.test.ts @@ -10,6 +10,7 @@ import { mockPEMKey, mockRsaJwk, mockRsaJwkKid, + pkTest, } from '../../fixtures'; import { server, validateHeaders } from '../../mock-server'; import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from '../keys'; @@ -124,6 +125,65 @@ describe('tokens.loadClerkJWKFromRemote(options)', () => { expect(jwk).toMatchObject(mockRsaJwk); }); + it('loads JWKS from the Frontend API when only a publishableKey is provided', async () => { + server.use( + http.get('https://clerk.inspired.puma-74.lcl.dev/.well-known/jwks.json', () => { + return HttpResponse.json(mockJwks); + }), + ); + + const jwk = await loadClerkJWKFromRemote({ + publishableKey: pkTest, + kid: mockRsaJwkKid, + skipJwksCache: true, + }); + + expect(jwk).toMatchObject(mockRsaJwk); + }); + + it('prefers the Backend API when both a secretKey and a publishableKey are provided', async () => { + const fapiHandler = vi.fn(() => HttpResponse.json(mockJwks)); + server.use( + http.get( + 'https://api.clerk.com/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + http.get('https://clerk.inspired.puma-74.lcl.dev/.well-known/jwks.json', fapiHandler), + ); + + const jwk = await loadClerkJWKFromRemote({ + secretKey: 'sk_test_deadbeef', + publishableKey: pkTest, + kid: mockRsaJwkKid, + skipJwksCache: true, + }); + + expect(jwk).toMatchObject(mockRsaJwk); + expect(fapiHandler).not.toHaveBeenCalled(); + }); + + it('retries the Frontend API JWKS request before it fails', async () => { + server.use( + http.get('https://clerk.inspired.puma-74.lcl.dev/.well-known/jwks.json', () => { + return HttpResponse.json({}, { status: 503 }); + }), + ); + + await expect(async () => { + const promise = loadClerkJWKFromRemote({ + publishableKey: pkTest, + kid: mockRsaJwkKid, + skipJwksCache: true, + }); + void vi.advanceTimersByTimeAsync(10000); + await promise; + }).rejects.toThrowError( + 'Error loading Clerk JWKS from https://clerk.inspired.puma-74.lcl.dev/.well-known/jwks.json with code=503', + ); + }); + it('caches JWK by kid', async () => { server.use( http.get( diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 852e4ece880..10e3d0564b0 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -9,6 +9,8 @@ import { mockJwt, mockJwtPayload, mockM2MJwtPayload, + mockOAuthAccessTokenJwtPayload, + mockRsaJwkKid, signingJwks, } from '../../fixtures'; import { @@ -1626,6 +1628,140 @@ describe('tokens.authenticateRequest(options)', () => { ); }); + describe('OAuth token audience', () => { + const audience = 'https://mcp.example.test/mcp'; + const signOAuthJwt = async (claims: Record) => { + const { data } = await signJwt({ ...mockOAuthAccessTokenJwtPayload, ...claims }, signingJwks, { + algorithm: 'RS256', + header: { typ: 'at+jwt', kid: mockRsaJwkKid }, + }); + return data!; + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(mockJwtPayload.iat * 1000)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('returns authenticated state and exposes aud when the OAuth JWT is bound to the configured audience', async () => { + server.use( + http.get('https://api.clerk.test/v1/jwks', () => { + return HttpResponse.json(mockJwks); + }), + ); + + const request = mockRequest({ authorization: `Bearer ${await signOAuthJwt({ aud: audience })}` }); + const requestState = await authenticateRequest(request, mockOptions({ acceptsToken: 'oauth_token', audience })); + + expect(requestState).toBeMachineAuthenticated(); + expect(requestState.toAuth()).toMatchObject({ + tokenType: 'oauth_token', + isAuthenticated: true, + aud: [audience], + act: null, + clientId: 'client_2VTWUzvGC5UhdJCNx6xG1D98edc', + scopes: ['read:foo', 'write:bar'], + userId: 'user_2vYVtestTESTtestTESTtestTESTtest', + }); + }); + + test('returns unauthenticated state when the OAuth JWT is bound to another audience', async () => { + server.use( + http.get('https://api.clerk.test/v1/jwks', () => { + return HttpResponse.json(mockJwks); + }), + ); + + const request = mockRequest({ + authorization: `Bearer ${await signOAuthJwt({ aud: 'https://other.example.test' })}`, + }); + const requestState = await authenticateRequest(request, mockOptions({ acceptsToken: 'oauth_token', audience })); + + expect(requestState).toBeMachineUnauthenticated({ + tokenType: 'oauth_token', + reason: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: + 'Invalid OAuth access token audience (aud) ["https://other.example.test"]. Expected one of ["https://mcp.example.test/mcp"]. (code=token-verification-failed, status=n/a)', + }); + expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({ + tokenType: 'oauth_token', + isAuthenticated: false, + }); + }); + + test('returns unauthenticated state when the OAuth JWT has no aud and an audience is configured', async () => { + server.use( + http.get('https://api.clerk.test/v1/jwks', () => { + return HttpResponse.json(mockJwks); + }), + ); + + const request = mockRequest({ authorization: `Bearer ${mockSignedOAuthAccessTokenJwt}` }); + const requestState = await authenticateRequest(request, mockOptions({ acceptsToken: 'oauth_token', audience })); + + expect(requestState).toBeMachineUnauthenticated({ + tokenType: 'oauth_token', + reason: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: + 'Invalid OAuth access token audience (aud) []. Expected one of ["https://mcp.example.test/mcp"]. (code=token-verification-failed, status=n/a)', + }); + }); + + test('returns unauthenticated state when an opaque OAuth token is bound to another audience', async () => { + server.use( + http.post(mockMachineAuthResponses.oauth_token.endpoint, () => { + return HttpResponse.json({ ...mockVerificationResults.oauth_token, aud: ['https://other.example.test'] }); + }), + ); + + const request = mockRequest({ authorization: `Bearer ${mockTokens.oauth_token}` }); + const requestState = await authenticateRequest(request, mockOptions({ acceptsToken: 'oauth_token', audience })); + + expect(requestState).toBeMachineUnauthenticated({ + tokenType: 'oauth_token', + reason: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: + 'Invalid OAuth access token audience (aud) ["https://other.example.test"]. Expected one of ["https://mcp.example.test/mcp"]. (code=token-verification-failed, status=n/a)', + }); + }); + + test('verifies the OAuth JWT with the publishable key when no secret key is configured', async () => { + server.use( + http.get('https://clerk.inspired.puma-74.lcl.dev/.well-known/jwks.json', () => { + return HttpResponse.json(mockJwks); + }), + ); + + const request = mockRequest({ authorization: `Bearer ${await signOAuthJwt({ aud: audience })}` }); + const requestState = await authenticateRequest( + request, + mockOptions({ acceptsToken: 'oauth_token', audience, secretKey: undefined }), + ); + + expect(requestState).toBeMachineAuthenticated(); + expect(requestState.toAuth()).toMatchObject({ tokenType: 'oauth_token', aud: [audience] }); + }); + + test('returns unauthenticated state for an opaque OAuth token when no secret key is configured', async () => { + const request = mockRequest({ authorization: `Bearer ${mockTokens.oauth_token}` }); + const requestState = await authenticateRequest( + request, + mockOptions({ acceptsToken: 'oauth_token', secretKey: undefined }), + ); + + expect(requestState).toBeMachineUnauthenticated({ + tokenType: 'oauth_token', + reason: MachineTokenVerificationErrorCode.InvalidSecretKey, + message: + 'Opaque OAuth access tokens can only be verified with a Clerk secret key. (code=secret-key-invalid, status=n/a)', + }); + }); + }); + describe('Any Token Type Authentication', () => { test.each(tokenTypes)('accepts %s when acceptsToken is "any"', async tokenType => { const mockToken = mockTokens[tokenType]; diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 6a12115c01a..38332350744 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -9,11 +9,13 @@ import { mockJwtPayload, mockM2MJwtPayload, mockOAuthAccessTokenJwtPayload, + pkTest, signingJwks, } from '../../fixtures'; import { mockSignedOAuthAccessTokenJwt, mockSignedOAuthAccessTokenJwtApplicationTyp, + mockTokens, mockVerificationResults, } from '../../fixtures/machine'; import { signJwt } from '../../jwt/signJwt'; @@ -49,6 +51,19 @@ describe('tokens.verify(token, options)', () => { vi.useRealTimers(); }); + it('verifies the session JWT with the Frontend API JWKS when only a publishable key is provided', async () => { + server.use( + http.get('https://clerk.inspired.puma-74.lcl.dev/.well-known/jwks.json', () => { + return HttpResponse.json(mockJwks); + }), + ); + + const { data, errors } = await verifyToken(mockJwt, { publishableKey: pkTest, skipJwksCache: true }); + + expect(errors).toBeUndefined(); + expect(data).toEqual(mockJwtPayload); + }); + it('verifies the provided session JWT', async () => { server.use( http.get( @@ -571,6 +586,218 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { ); }); + describe('OAuth access token audience', () => { + const audience = 'https://mcp.example.test/mcp'; + const options = { apiUrl: 'https://api.clerk.test', secretKey: 'a-valid-key', audience }; + const oauthTokenJSON = { + object: 'clerk_idp_oauth_access_token', + id: 'oat_2VTWUzvGC5UhdJCNx6xG1D98edc', + client_id: 'client_2VTWUzvGC5UhdJCNx6xG1D98edc', + type: 'oauth:access_token', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + scopes: ['read:foo', 'write:bar'], + aud: [audience], + revoked: false, + revocation_reason: null, + expired: false, + expiration: null, + created_at: 1744928754551, + updated_at: 1744928754551, + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(mockJwtPayload.iat * 1000)); + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('verifies an OAuth JWT whose aud matches the configured audience', async () => { + const payload = { ...mockOAuthAccessTokenJwtPayload, aud: audience }; + const result = await verifyMachineAuthToken(await createSignedOAuthJwt(payload), options); + + expect(result.errors).toBeUndefined(); + expect((result.data as IdPOAuthAccessToken).aud).toEqual([audience]); + }); + + it('accepts an aud array that includes the configured audience', async () => { + const payload = { ...mockOAuthAccessTokenJwtPayload, aud: ['https://other.example.test', audience] }; + const result = await verifyMachineAuthToken(await createSignedOAuthJwt(payload), options); + + expect(result.errors).toBeUndefined(); + expect((result.data as IdPOAuthAccessToken).aud).toEqual(['https://other.example.test', audience]); + }); + + it('accepts an OAuth JWT bound to one of several configured audiences', async () => { + const payload = { ...mockOAuthAccessTokenJwtPayload, aud: audience }; + const result = await verifyMachineAuthToken(await createSignedOAuthJwt(payload), { + ...options, + audience: ['https://other.example.test', audience], + }); + + expect(result.errors).toBeUndefined(); + }); + + it('rejects an OAuth JWT whose aud does not match the configured audience', async () => { + const payload = { ...mockOAuthAccessTokenJwtPayload, aud: 'https://other.example.test' }; + const result = await verifyMachineAuthToken(await createSignedOAuthJwt(payload), options); + + expect(result.data).toBeUndefined(); + expect(result.errors?.[0].code).toBe('token-verification-failed'); + expect(result.errors?.[0].message).toBe( + 'Invalid OAuth access token audience (aud) ["https://other.example.test"]. Expected one of ["https://mcp.example.test/mcp"].', + ); + }); + + it('rejects an OAuth JWT without an aud claim when an audience is configured', async () => { + const result = await verifyMachineAuthToken(mockSignedOAuthAccessTokenJwt, options); + + expect(result.data).toBeUndefined(); + expect(result.errors?.[0].code).toBe('token-verification-failed'); + expect(result.errors?.[0].message).toBe( + 'Invalid OAuth access token audience (aud) []. Expected one of ["https://mcp.example.test/mcp"].', + ); + }); + + it('accepts an OAuth JWT without an aud claim when no audience is configured', async () => { + const result = await verifyMachineAuthToken(mockSignedOAuthAccessTokenJwt, { ...options, audience: undefined }); + + expect(result.errors).toBeUndefined(); + expect((result.data as IdPOAuthAccessToken).aud).toEqual([]); + }); + + it('exposes the act claim of an OAuth JWT', async () => { + const act = { sub: 'client_2agentTESTtestTESTtestTESTtest' }; + const payload = { ...mockOAuthAccessTokenJwtPayload, aud: audience, act }; + const result = await verifyMachineAuthToken(await createSignedOAuthJwt(payload), options); + + expect(result.errors).toBeUndefined(); + expect((result.data as IdPOAuthAccessToken).act).toEqual(act); + }); + + it('verifies an opaque OAuth token whose aud matches the configured audience', async () => { + server.use( + http.post( + 'https://api.clerk.test/oauth_applications/access_tokens/verify', + validateHeaders(() => { + return HttpResponse.json(oauthTokenJSON); + }), + ), + ); + + const result = await verifyMachineAuthToken(mockTokens.oauth_token, options); + + expect(result.errors).toBeUndefined(); + expect((result.data as IdPOAuthAccessToken).aud).toEqual([audience]); + expect((result.data as IdPOAuthAccessToken).act).toBeNull(); + }); + + it('rejects an opaque OAuth token whose aud does not match the configured audience', async () => { + server.use( + http.post( + 'https://api.clerk.test/oauth_applications/access_tokens/verify', + validateHeaders(() => { + return HttpResponse.json({ ...oauthTokenJSON, aud: ['https://other.example.test'] }); + }), + ), + ); + + const result = await verifyMachineAuthToken(mockTokens.oauth_token, options); + + expect(result.data).toBeUndefined(); + expect(result.errors?.[0].code).toBe('token-verification-failed'); + expect(result.errors?.[0].message).toBe( + 'Invalid OAuth access token audience (aud) ["https://other.example.test"]. Expected one of ["https://mcp.example.test/mcp"].', + ); + }); + + it('rejects an opaque OAuth token without an aud when an audience is configured', async () => { + const { aud: _aud, ...withoutAud } = oauthTokenJSON; + server.use( + http.post( + 'https://api.clerk.test/oauth_applications/access_tokens/verify', + validateHeaders(() => { + return HttpResponse.json(withoutAud); + }), + ), + ); + + const result = await verifyMachineAuthToken(mockTokens.oauth_token, options); + + expect(result.data).toBeUndefined(); + expect(result.errors?.[0].code).toBe('token-verification-failed'); + expect(result.errors?.[0].message).toBe( + 'Invalid OAuth access token audience (aud) []. Expected one of ["https://mcp.example.test/mcp"].', + ); + }); + + it('accepts an opaque OAuth token without an aud when no audience is configured', async () => { + const { aud: _aud, ...withoutAud } = oauthTokenJSON; + server.use( + http.post( + 'https://api.clerk.test/oauth_applications/access_tokens/verify', + validateHeaders(() => { + return HttpResponse.json(withoutAud); + }), + ), + ); + + const result = await verifyMachineAuthToken(mockTokens.oauth_token, { ...options, audience: undefined }); + + expect(result.errors).toBeUndefined(); + expect((result.data as IdPOAuthAccessToken).aud).toEqual([]); + }); + }); + + describe('verifyOAuthToken with a publishable key only', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(mockJwtPayload.iat * 1000)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('verifies an OAuth JWT with the Frontend API JWKS when no secret key is provided', async () => { + server.use( + http.get('https://clerk.inspired.puma-74.lcl.dev/.well-known/jwks.json', () => { + return HttpResponse.json(mockJwks); + }), + ); + + const result = await verifyMachineAuthToken(mockSignedOAuthAccessTokenJwt, { + publishableKey: pkTest, + skipJwksCache: true, + }); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.errors).toBeUndefined(); + expect((result.data as IdPOAuthAccessToken).clientId).toBe('client_2VTWUzvGC5UhdJCNx6xG1D98edc'); + }); + + it('rejects an opaque OAuth token when no secret key is provided', async () => { + const result = await verifyMachineAuthToken(mockTokens.oauth_token, { publishableKey: pkTest }); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toBeUndefined(); + expect(result.errors?.[0].code).toBe('secret-key-invalid'); + expect(result.errors?.[0].message).toBe( + 'Opaque OAuth access tokens can only be verified with a Clerk secret key.', + ); + }); + }); + describe('verifyM2MToken with JWT', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 44391b388da..b75d6530bd9 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -1,6 +1,7 @@ import { createCheckAuthorization } from '@clerk/shared/authorization'; import { __experimental_JWTPayloadToAuthObjectProperties } from '@clerk/shared/jwtPayloadParser'; import type { + ActClaim, CheckAuthorizationFromSessionClaims, Jwt, JwtPayload, @@ -102,6 +103,8 @@ type MachineObjectExtendedProperties = { oauth_token: { userId: TAuthenticated extends true ? string : null; clientId: TAuthenticated extends true ? string : null; + aud: TAuthenticated extends true ? string[] : null; + act: TAuthenticated extends true ? ActClaim | null : null; }; }; @@ -310,6 +313,8 @@ export function authenticatedMachineObject( scopes: result.scopes, userId: result.subject, clientId: result.clientId, + aud: result.aud, + act: result.act, } as unknown as AuthenticatedMachineObject; } default: @@ -362,6 +367,8 @@ export function unauthenticatedMachineObject( scopes: null, userId: null, clientId: null, + aud: null, + act: null, } as unknown as UnauthenticatedMachineObject; } default: diff --git a/packages/backend/src/tokens/keys.ts b/packages/backend/src/tokens/keys.ts index 4966bbfd7d7..1ab0f603d19 100644 --- a/packages/backend/src/tokens/keys.ts +++ b/packages/backend/src/tokens/keys.ts @@ -13,7 +13,7 @@ import { } from '../errors'; import { runtime } from '../runtime'; import { joinPaths } from '../util/path'; -import { retry } from '../util/shared'; +import { parsePublishableKey, retry } from '../util/shared'; type JsonWebKeyWithKid = JsonWebKey & { kid: string }; @@ -112,6 +112,10 @@ export type LoadClerkJWKFromRemoteOptions = { * The Clerk Secret Key from the [**API keys**](https://dashboard.clerk.com/~/api-keys) page in the Clerk Dashboard. */ secretKey?: string; + /** + * The Clerk Publishable Key from the [**API keys**](https://dashboard.clerk.com/~/api-keys) page in the Clerk Dashboard. Used to load the JWKS from the Frontend API when no `secretKey` is provided. + */ + publishableKey?: string; /** * The [Clerk Backend API](https://clerk.com/docs/reference/backend-api){{ target: '_blank' }} endpoint. * @default 'https://api.clerk.com' @@ -126,7 +130,8 @@ export type LoadClerkJWKFromRemoteOptions = { /** * - * Loads a key from JWKS retrieved from the well-known Frontend API endpoint of the issuer. + * Loads a key from the JWKS of the Clerk instance: from the Backend API when a `secretKey` is provided, + * otherwise from the well-known Frontend API endpoint derived from the `publishableKey`. * The result is also cached on the module level to avoid network requests in subsequent invocations. * The cache lasts up to 5 minutes. * @@ -136,19 +141,20 @@ export type LoadClerkJWKFromRemoteOptions = { * @returns {JsonWebKey} key */ export async function loadClerkJWKFromRemote(params: LoadClerkJWKFromRemoteOptions): Promise { - const { secretKey, apiUrl = API_URL, apiVersion = API_VERSION, kid, skipJwksCache } = params; + const { secretKey, publishableKey, apiUrl = API_URL, apiVersion = API_VERSION, kid, skipJwksCache } = params; - const cache = getRemoteCache(`${apiUrl}|${apiVersion}|${secretKey ?? ''}`); + const cache = getRemoteCache(secretKey ? `${apiUrl}|${apiVersion}|${secretKey}` : (publishableKey ?? '')); if (skipJwksCache || cacheHasExpired(cache) || !cache.keys[kid]) { - if (!secretKey) { + if (!secretKey && !publishableKey) { throw new TokenVerificationError({ action: TokenVerificationErrorAction.ContactSupport, message: 'Failed to load JWKS from Clerk Backend or Frontend API.', reason: TokenVerificationErrorReason.RemoteJWKFailedToLoad, }); } - const fetcher = () => fetchJWKSFromBAPI(apiUrl, secretKey, apiVersion) as Promise<{ keys: JsonWebKeyWithKid[] }>; + const fetcher = () => + secretKey ? fetchJWKSFromBAPI(apiUrl, secretKey, apiVersion) : fetchJWKSFromFAPI(publishableKey); const { keys } = await retry(fetcher); if (!keys || !keys.length) { @@ -180,7 +186,11 @@ export async function loadClerkJWKFromRemote(params: LoadClerkJWKFromRemoteOptio return jwk; } -async function fetchJWKSFromBAPI(apiUrl: string, key: string, apiVersion: string) { +async function fetchJWKSFromBAPI( + apiUrl: string, + key: string, + apiVersion: string, +): Promise<{ keys: JsonWebKeyWithKid[] }> { if (!key) { throw new TokenVerificationError({ action: TokenVerificationErrorAction.SetClerkSecretKey, @@ -226,6 +236,23 @@ async function fetchJWKSFromBAPI(apiUrl: string, key: string, apiVersion: string return response.json(); } +async function fetchJWKSFromFAPI(publishableKey: string | undefined): Promise<{ keys: JsonWebKeyWithKid[] }> { + const { frontendApi } = parsePublishableKey(publishableKey, { fatal: true }); + const url = `https://${frontendApi}/.well-known/jwks.json`; + + const response = await runtime.fetch(url, { headers: { 'User-Agent': USER_AGENT } }); + + if (!response.ok) { + throw new TokenVerificationError({ + action: TokenVerificationErrorAction.ContactSupport, + message: `Error loading Clerk JWKS from ${url} with code=${response.status}`, + reason: TokenVerificationErrorReason.RemoteJWKFailedToLoad, + }); + } + + return response.json(); +} + function cacheHasExpired(cache: RemoteJwksCache) { // If the cache has expired, clear the value so we don't attempt to make decisions based on stale data const isExpired = Date.now() - cache.lastUpdatedAt >= MAX_CACHE_LAST_UPDATED_AT_SECONDS * 1000; diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 07f16f0a97e..c8e72089f9e 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -155,8 +155,9 @@ export const authenticateRequest: AuthenticateRequest = (async ( // Default tokenType is session_token for backwards compatibility. const acceptsToken = options.acceptsToken ?? TokenType.SessionToken; - // machine-to-machine tokens can accept a machine secret or a secret key - if (acceptsToken !== TokenType.M2MToken) { + // M2M tokens can accept a machine secret or a secret key, and OAuth JWTs can be verified + // with the publishable key alone, so neither requires a secret key up front. + if (acceptsToken !== TokenType.M2MToken && acceptsToken !== TokenType.OAuthToken) { assertValidSecretKey(authenticateContext.secretKey); if (authenticateContext.isSatellite) { diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index 3921b6ca04c..9fc9b346a92 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -13,7 +13,7 @@ import { import type { VerifyJwtOptions } from '../jwt'; import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types'; import { decodeJwt, verifyJwt } from '../jwt/verifyJwt'; -import { verifyM2MJwt, verifyOAuthJwt } from '../jwt/verifyMachineJwt'; +import { verifyM2MJwt, verifyOAuthAudience, verifyOAuthJwt } from '../jwt/verifyMachineJwt'; import { JWT_CATEGORY_M2M_TOKEN } from './jwtCategories'; import type { LoadClerkJWKFromRemoteOptions } from './keys'; import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys'; @@ -45,7 +45,7 @@ export type VerifyTokenOptions = Simplify< * > [!WARNING] * > This is a lower-level method intended for more advanced use-cases. It's recommended to use [`authenticateRequest()`](https://clerk.com/docs/reference/backend/authenticate-request), which fully authenticates a token passed from the `request` object. * - * Verifies a Clerk-generated token signature. Networkless if the `jwtKey` is provided. Otherwise, performs a network call to retrieve the JWKS from the [Backend API](https://clerk.com/docs/reference/backend-api/tag/jwks/GET/jwks){{ target: '_blank' }}. + * Verifies a Clerk-generated token signature. Networkless if the `jwtKey` is provided. Otherwise, performs a network call to retrieve the JWKS from the [Backend API](https://clerk.com/docs/reference/backend-api/tag/jwks/GET/jwks){{ target: '_blank' }} when a `secretKey` is provided, or from the Frontend API when only a `publishableKey` is provided. * * @param token - The token to verify. * @param options - Options for verifying the token. It is recommended to set these options as [environment variables](/docs/guides/development/clerk-environment-variables#api-and-sdk-configuration) where possible, and then pass them to the function. For example, you can set the `secretKey` option using the `CLERK_SECRET_KEY` environment variable, and then pass it to the function like this: `verifyToken(token, { secretKey: process.env.CLERK_SECRET_KEY })`. @@ -139,7 +139,7 @@ export async function verifyToken( if (options.jwtKey) { key = loadClerkJwkFromPem({ kid, pem: options.jwtKey }); - } else if (options.secretKey) { + } else if (options.secretKey || options.publishableKey) { key = await loadClerkJWKFromRemote({ ...options, kid }); } else { return { @@ -225,9 +225,27 @@ async function verifyOAuthToken( accessToken: string, options: VerifyTokenOptions, ): Promise> { + if (!options.secretKey) { + return { + data: undefined, + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.InvalidSecretKey, + message: 'Opaque OAuth access tokens can only be verified with a Clerk secret key.', + action: TokenVerificationErrorAction.SetClerkSecretKey, + }), + ], + }; + } + try { const client = createBackendApiClient(options); const verifiedToken = await client.idPOAuthAccessToken.verify(accessToken); + const audienceError = verifyOAuthAudience(verifiedToken.aud, options.audience); + if (audienceError) { + return { data: undefined, tokenType: TokenType.OAuthToken, errors: [audienceError] }; + } return { data: verifiedToken, tokenType: TokenType.OAuthToken, errors: undefined }; } catch (err: any) { return handleClerkAPIError(TokenType.OAuthToken, err, 'OAuth token not found');