From 67c505847290cddbd065ae2c59a7aa40d1515a63 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Nov 2026 17:41:40 -0600 Subject: [PATCH 01/14] feat(backend): add experimental OAuth application token exchange --- .../oauth-application-token-exchange.md | 5 + .../__tests__/OAuthApplicationsApi.test.ts | 162 ++++++++++++++++++ .../src/api/endpoints/OAuthApplicationsApi.ts | 146 ++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 .changeset/oauth-application-token-exchange.md diff --git a/.changeset/oauth-application-token-exchange.md b/.changeset/oauth-application-token-exchange.md new file mode 100644 index 00000000000..bf466a31049 --- /dev/null +++ b/.changeset/oauth-application-token-exchange.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass the application ID, its client secret, and the subject token to receive a new token issued by the application's token endpoint, for example to call a downstream API on a user's behalf. diff --git a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts index 14afa71e0ec..98f081956d3 100644 --- a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts +++ b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts @@ -75,4 +75,166 @@ describe('OAuthApplications', () => { ).rejects.toThrow('A valid resource ID is required.'); }); }); + + describe('__experimental_exchangeToken', () => { + const tokenFetchUrl = 'https://clerk.example.test/oauth/token'; + const clientId = 'client id:with+reserved'; + const clientSecret = 'secret value:+/='; + + const mockOAuthApplication = { + object: 'oauth_application', + id: oauthApplicationId, + instance_id: 'ins_xxxxx', + name: 'Example', + client_id: clientId, + client_uri: null, + client_image_url: null, + dynamically_registered: false, + consent_screen_enabled: true, + pkce_required: false, + public: false, + scopes: 'profile email', + redirect_uris: [], + authorize_url: 'https://clerk.example.test/oauth/authorize', + token_fetch_url: tokenFetchUrl, + user_info_url: 'https://clerk.example.test/oauth/userinfo', + discovery_url: 'https://clerk.example.test/.well-known/openid-configuration', + token_introspection_url: 'https://clerk.example.test/oauth/token_info', + created_at: 1, + updated_at: 1, + }; + + const createClient = () => + createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_xxxxx', + }); + + const useApplication = () => + server.use( + http.get( + `https://api.clerk.test/v1/oauth_applications/${oauthApplicationId}`, + validateHeaders(() => HttpResponse.json(mockOAuthApplication)), + ), + ); + + it('exchanges a subject token at the application token endpoint', async () => { + useApplication(); + + let captured: { headers: Headers; body: URLSearchParams } | undefined; + server.use( + http.post(tokenFetchUrl, async ({ request }) => { + captured = { headers: request.headers, body: new URLSearchParams(await request.text()) }; + return HttpResponse.json({ + access_token: 'exchanged_token', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'invoices:read invoices:write', + }); + }), + ); + + const result = await createClient().oauthApplications.__experimental_exchangeToken({ + oauthApplicationId, + clientSecret, + subjectToken: 'subject_token', + subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + actorToken: 'actor_token', + actorTokenType: 'urn:ietf:params:oauth:token-type:jwt', + requestedTokenType: 'urn:ietf:params:oauth:token-type:access_token', + audience: ['https://a.example.test', 'https://b.example.test'], + resource: 'https://r.example.test', + scope: ['invoices:read', 'invoices:write'], + }); + + expect(result).toEqual({ + accessToken: 'exchanged_token', + issuedTokenType: 'urn:ietf:params:oauth:token-type:access_token', + tokenType: 'Bearer', + expiresIn: 3600, + scope: ['invoices:read', 'invoices:write'], + refreshToken: undefined, + }); + + expect(captured?.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); + expect(captured?.headers.get('Accept')).toBe('application/json'); + + const body = captured?.body; + expect(body?.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:token-exchange'); + expect(body?.get('subject_token')).toBe('subject_token'); + expect(body?.get('subject_token_type')).toBe('urn:ietf:params:oauth:token-type:access_token'); + expect(body?.get('actor_token')).toBe('actor_token'); + expect(body?.get('actor_token_type')).toBe('urn:ietf:params:oauth:token-type:jwt'); + expect(body?.get('requested_token_type')).toBe('urn:ietf:params:oauth:token-type:access_token'); + expect(body?.getAll('audience')).toEqual(['https://a.example.test', 'https://b.example.test']); + expect(body?.getAll('resource')).toEqual(['https://r.example.test']); + expect(body?.get('scope')).toBe('invoices:read invoices:write'); + expect(body?.has('client_id')).toBe(false); + }); + + it('authenticates with form-urlencoded client credentials over HTTP Basic', async () => { + useApplication(); + + let authorization: string | null = null; + server.use( + http.post(tokenFetchUrl, ({ request }) => { + authorization = request.headers.get('Authorization'); + return HttpResponse.json({ + access_token: 'exchanged_token', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + }); + }), + ); + + await createClient().oauthApplications.__experimental_exchangeToken({ + oauthApplicationId, + clientSecret, + subjectToken: 'subject_token', + subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + }); + + expect(authorization).toBe(`Basic ${btoa('client+id%3Awith%2Breserved:secret+value%3A%2B%2F%3D')}`); + }); + + it('omits optional parameters that are not provided', async () => { + useApplication(); + + let body: URLSearchParams | undefined; + server.use( + http.post(tokenFetchUrl, async ({ request }) => { + body = new URLSearchParams(await request.text()); + return HttpResponse.json({ + access_token: 'exchanged_token', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'N_A', + refresh_token: 'refresh', + }); + }), + ); + + const result = await createClient().oauthApplications.__experimental_exchangeToken({ + oauthApplicationId, + clientSecret, + subjectToken: 'subject_token', + subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + }); + + expect([...(body?.keys() ?? [])]).toEqual(['grant_type', 'subject_token', 'subject_token_type']); + expect(result.scope).toBeUndefined(); + expect(result.refreshToken).toBe('refresh'); + }); + + it('throws when the OAuth application ID is missing', async () => { + await expect( + createClient().oauthApplications.__experimental_exchangeToken({ + oauthApplicationId: '', + clientSecret, + subjectToken: 'subject_token', + subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + }), + ).rejects.toThrow('A valid resource ID is required.'); + }); + }); }); diff --git a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts index 620e0d57f2b..9017193d86f 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -1,5 +1,6 @@ import type { ClerkPaginationRequest } from '@clerk/shared/types'; +import { runtime } from '../../runtime'; import { joinPaths } from '../../util/path'; import type { DeletedObject } from '../resources'; import type { PaginatedResourceResponse } from '../resources/Deserializer'; @@ -37,6 +38,108 @@ export type RevokeOAuthApplicationTokenParams = { token: string; }; +/** + * A token type identifier as defined in [RFC 8693 section 3](https://www.rfc-editor.org/rfc/rfc8693#section-3). + */ +export type OAuthTokenTypeIdentifier = + | 'urn:ietf:params:oauth:token-type:access_token' + | 'urn:ietf:params:oauth:token-type:refresh_token' + | 'urn:ietf:params:oauth:token-type:id_token' + | 'urn:ietf:params:oauth:token-type:jwt' + | (string & {}); + +/** + * The parameters for performing an [OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693) for an OAuth application. + */ +export type ExchangeOAuthApplicationTokenParams = { + /** The ID of the OAuth application performing the exchange. */ + oauthApplicationId: string; + /** The client secret of the OAuth application, used to authenticate with the token endpoint. */ + clientSecret: string; + /** The token that represents the identity of the party on behalf of whom the request is being made. */ + subjectToken: string; + /** The type of `subjectToken`. */ + subjectTokenType: OAuthTokenTypeIdentifier; + /** The token that represents the identity of the acting party, for delegation. */ + actorToken?: string; + /** The type of `actorToken`. Required when `actorToken` is provided. */ + actorTokenType?: OAuthTokenTypeIdentifier; + /** The type of token being requested. */ + requestedTokenType?: OAuthTokenTypeIdentifier; + /** The logical name or names of the target service where the issued token will be used. */ + audience?: string | string[]; + /** The URI or URIs of the target service or resource where the issued token will be used. */ + resource?: string | string[]; + /** The scopes requested for the issued token. */ + scope?: string[]; +}; + +/** + * The result of a successful [OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693#section-2.2.1). + */ +export type ExchangeOAuthApplicationTokenResult = { + /** The issued security token. */ + accessToken: string; + /** The type of the issued token. */ + issuedTokenType: OAuthTokenTypeIdentifier; + /** How the issued token is used, for example `Bearer`. */ + tokenType: string; + /** The lifetime of the issued token in seconds, if provided by the authorization server. */ + expiresIn?: number; + /** The scopes of the issued token, if provided by the authorization server. */ + scope?: string[]; + /** A refresh token, if issued by the authorization server. */ + refreshToken?: string; +}; + +const TOKEN_EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'; + +const formUrlEncode = (value: string) => new URLSearchParams({ v: value }).toString().slice(2); + +const appendAll = (body: URLSearchParams, key: string, value: string | string[] | undefined) => { + if (value === undefined) { + return; + } + for (const v of Array.isArray(value) ? value : [value]) { + body.append(key, v); + } +}; + +const buildTokenExchangeBody = (params: ExchangeOAuthApplicationTokenParams) => { + const body = new URLSearchParams({ + grant_type: TOKEN_EXCHANGE_GRANT_TYPE, + subject_token: params.subjectToken, + subject_token_type: params.subjectTokenType, + }); + appendAll(body, 'actor_token', params.actorToken); + appendAll(body, 'actor_token_type', params.actorTokenType); + appendAll(body, 'requested_token_type', params.requestedTokenType); + appendAll(body, 'audience', params.audience); + appendAll(body, 'resource', params.resource); + if (params.scope?.length) { + body.set('scope', params.scope.join(' ')); + } + return body; +}; + +type TokenExchangeResponseJSON = { + access_token: string; + issued_token_type: string; + token_type: string; + expires_in?: number; + scope?: string; + refresh_token?: string; +}; + +const toTokenExchangeResult = (json: TokenExchangeResponseJSON): ExchangeOAuthApplicationTokenResult => ({ + accessToken: json.access_token, + issuedTokenType: json.issued_token_type, + tokenType: json.token_type, + expiresIn: json.expires_in, + scope: json.scope ? json.scope.split(' ') : undefined, + refreshToken: json.refresh_token, +}); + /** @generateWithEmptyComment */ export type GetOAuthApplicationListParams = ClerkPaginationRequest<{ /** @@ -149,4 +252,47 @@ export class OAuthApplicationsApi extends AbstractAPI { bodyParams, }); } + + /** + * @experimental This is an experimental API for OAuth 2.0 Token Exchange and is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version to avoid breaking changes. + * + * Performs an [OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693) for the given [`OAuthApplication`](/docs/reference/backend/types/backend-oauth-application), exchanging a subject token for a new token issued by the application's token endpoint. + * + * @param params - The parameters for the token exchange. + * @returns The issued token and its metadata. + * + * @example + * ```ts + * const { accessToken } = await clerkClient.oauthApplications.__experimental_exchangeToken({ + * oauthApplicationId: 'oauthapp_123', + * clientSecret: process.env.OAUTH_CLIENT_SECRET, + * subjectToken, + * subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + * audience: 'https://api.example.com', + * }); + * ``` + */ + public async __experimental_exchangeToken( + params: ExchangeOAuthApplicationTokenParams, + ): Promise { + const application = await this.get(params.oauthApplicationId); + + const credentials = `${formUrlEncode(application.clientId)}:${formUrlEncode(params.clientSecret)}`; + + const response = await runtime.fetch(application.tokenFetchUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Basic ${btoa(credentials)}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: buildTokenExchangeBody(params), + }); + + if (!response.ok) { + throw new Error(`OAuth token exchange failed with status ${response.status}.`); + } + + return toTokenExchangeResult((await response.json()) as TokenExchangeResponseJSON); + } } From 60e2a265d81d7703779f497df0e2540132fa8dde Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Nov 2026 17:43:27 -0600 Subject: [PATCH 02/14] feat(backend): throw typed errors from experimental OAuth token exchange --- .../oauth-application-token-exchange.md | 2 +- .../backend/src/__tests__/exports.test.ts | 1 + .../__tests__/OAuthApplicationsApi.test.ts | 76 +++++++++++++++++++ .../src/api/endpoints/OAuthApplicationsApi.ts | 35 ++++++++- packages/backend/src/errors.ts | 15 ++++ 5 files changed, 126 insertions(+), 3 deletions(-) diff --git a/.changeset/oauth-application-token-exchange.md b/.changeset/oauth-application-token-exchange.md index bf466a31049..da1597d8839 100644 --- a/.changeset/oauth-application-token-exchange.md +++ b/.changeset/oauth-application-token-exchange.md @@ -2,4 +2,4 @@ '@clerk/backend': minor --- -Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass the application ID, its client secret, and the subject token to receive a new token issued by the application's token endpoint, for example to call a downstream API on a user's behalf. +Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass the application ID, its client secret, and the subject token to receive a new token issued by the application's token endpoint, for example to call a downstream API on a user's behalf. If the token endpoint rejects the exchange, the method throws an `OAuthTokenExchangeError`, exported from `@clerk/backend/errors`, with the OAuth `error` code, `errorDescription`, and HTTP `status`. diff --git a/packages/backend/src/__tests__/exports.test.ts b/packages/backend/src/__tests__/exports.test.ts index 7892bf9f554..f4b144b9c1f 100644 --- a/packages/backend/src/__tests__/exports.test.ts +++ b/packages/backend/src/__tests__/exports.test.ts @@ -25,6 +25,7 @@ describe('subpath /errors exports', () => { "ClerkAPIResponseError", "MachineTokenVerificationError", "MachineTokenVerificationErrorCode", + "OAuthTokenExchangeError", "SignJWTError", "TokenVerificationError", "TokenVerificationErrorAction", diff --git a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts index 98f081956d3..73893a53590 100644 --- a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts +++ b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts @@ -1,6 +1,7 @@ import { http, HttpResponse } from 'msw'; import { describe, expect, it } from 'vitest'; +import { OAuthTokenExchangeError } from '../../errors'; import { server, validateHeaders } from '../../mock-server'; import { createBackendApiClient } from '../factory'; @@ -226,6 +227,81 @@ describe('OAuthApplications', () => { expect(result.refreshToken).toBe('refresh'); }); + it.each([ + ['unsupported_grant_type', 'The authorization grant type is not supported.'], + ['invalid_grant', undefined], + ['invalid_target', 'Unknown audience.'], + ])('throws OAuthTokenExchangeError for a %s error response', async (error, errorDescription) => { + useApplication(); + server.use( + http.post(tokenFetchUrl, () => + HttpResponse.json( + { error, ...(errorDescription && { error_description: errorDescription }) }, + { status: 400 }, + ), + ), + ); + + const promise = createClient().oauthApplications.__experimental_exchangeToken({ + oauthApplicationId, + clientSecret, + subjectToken: 'subject_token', + subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + }); + + await expect(promise).rejects.toBeInstanceOf(OAuthTokenExchangeError); + await expect(promise).rejects.toMatchObject({ error, errorDescription, status: 400 }); + }); + + it('throws an invalid_response error when the error body is not JSON', async () => { + useApplication(); + server.use(http.post(tokenFetchUrl, () => new HttpResponse('Bad Gateway', { status: 502 }))); + + await expect( + createClient().oauthApplications.__experimental_exchangeToken({ + oauthApplicationId, + clientSecret, + subjectToken: 'subject_token', + subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + }), + ).rejects.toMatchObject({ error: 'invalid_response', errorDescription: undefined, status: 502 }); + }); + + it('throws an invalid_response error when a successful response is malformed', async () => { + useApplication(); + server.use(http.post(tokenFetchUrl, () => HttpResponse.json({ token_type: 'Bearer' }))); + + await expect( + createClient().oauthApplications.__experimental_exchangeToken({ + oauthApplicationId, + clientSecret, + subjectToken: 'subject_token', + subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + }), + ).rejects.toMatchObject({ error: 'invalid_response', status: 200 }); + }); + + it('rejects actorTokenType without actorToken before making any request', async () => { + let requested = false; + server.use( + http.all('*', () => { + requested = true; + return HttpResponse.json({}); + }), + ); + + await expect( + createClient().oauthApplications.__experimental_exchangeToken({ + oauthApplicationId, + clientSecret, + subjectToken: 'subject_token', + subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + actorTokenType: 'urn:ietf:params:oauth:token-type:jwt', + }), + ).rejects.toThrow('`actorTokenType` requires `actorToken`.'); + expect(requested).toBe(false); + }); + it('throws when the OAuth application ID is missing', async () => { await expect( createClient().oauthApplications.__experimental_exchangeToken({ diff --git a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts index 9017193d86f..52758316ba2 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -1,5 +1,6 @@ import type { ClerkPaginationRequest } from '@clerk/shared/types'; +import { OAuthTokenExchangeError } from '../../errors'; import { runtime } from '../../runtime'; import { joinPaths } from '../../util/path'; import type { DeletedObject } from '../resources'; @@ -140,6 +141,25 @@ const toTokenExchangeResult = (json: TokenExchangeResponseJSON): ExchangeOAuthAp refreshToken: json.refresh_token, }); +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + +const isTokenExchangeResponse = (value: unknown): value is TokenExchangeResponseJSON => + isRecord(value) && + typeof value.access_token === 'string' && + typeof value.issued_token_type === 'string' && + typeof value.token_type === 'string'; + +const toTokenExchangeError = (json: unknown, status: number) => { + if (!isRecord(json) || typeof json.error !== 'string') { + return new OAuthTokenExchangeError({ error: 'invalid_response', status }); + } + return new OAuthTokenExchangeError({ + error: json.error, + errorDescription: typeof json.error_description === 'string' ? json.error_description : undefined, + status, + }); +}; + /** @generateWithEmptyComment */ export type GetOAuthApplicationListParams = ClerkPaginationRequest<{ /** @@ -260,6 +280,7 @@ export class OAuthApplicationsApi extends AbstractAPI { * * @param params - The parameters for the token exchange. * @returns The issued token and its metadata. + * @throws `OAuthTokenExchangeError` if the token endpoint rejects the exchange or returns an invalid response. * * @example * ```ts @@ -275,6 +296,10 @@ export class OAuthApplicationsApi extends AbstractAPI { public async __experimental_exchangeToken( params: ExchangeOAuthApplicationTokenParams, ): Promise { + if (params.actorTokenType && !params.actorToken) { + throw new Error('`actorTokenType` requires `actorToken`.'); + } + const application = await this.get(params.oauthApplicationId); const credentials = `${formUrlEncode(application.clientId)}:${formUrlEncode(params.clientSecret)}`; @@ -289,10 +314,16 @@ export class OAuthApplicationsApi extends AbstractAPI { body: buildTokenExchangeBody(params), }); + const json = await response.json().catch(() => undefined); + if (!response.ok) { - throw new Error(`OAuth token exchange failed with status ${response.status}.`); + throw toTokenExchangeError(json, response.status); + } + + if (!isTokenExchangeResponse(json)) { + throw new OAuthTokenExchangeError({ error: 'invalid_response', status: response.status }); } - return toTokenExchangeResult((await response.json()) as TokenExchangeResponseJSON); + return toTokenExchangeResult(json); } } diff --git a/packages/backend/src/errors.ts b/packages/backend/src/errors.ts index 3ebc0bf9234..744e165e149 100644 --- a/packages/backend/src/errors.ts +++ b/packages/backend/src/errors.ts @@ -117,3 +117,18 @@ export class MachineTokenVerificationError extends ClerkError { return `${this.message} (code=${this.code}, status=${this.status || 'n/a'})`; } } + +export class OAuthTokenExchangeError extends Error { + readonly error: string; + readonly errorDescription?: string; + readonly status?: number; + + constructor({ error, errorDescription, status }: { error: string; errorDescription?: string; status?: number }) { + super(errorDescription ? `${error}: ${errorDescription}` : error); + Object.setPrototypeOf(this, OAuthTokenExchangeError.prototype); + this.name = 'OAuthTokenExchangeError'; + this.error = error; + this.errorDescription = errorDescription; + this.status = status; + } +} From ada5e94c4bd0a92eca856b230739e9695e05fa60 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Nov 2026 17:44:26 -0600 Subject: [PATCH 03/14] feat(backend): support public clients in experimental OAuth token exchange --- .../oauth-application-token-exchange.md | 2 +- .../__tests__/OAuthApplicationsApi.test.ts | 28 +++++++++++++++++++ .../src/api/endpoints/OAuthApplicationsApi.ts | 27 ++++++++++-------- 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/.changeset/oauth-application-token-exchange.md b/.changeset/oauth-application-token-exchange.md index da1597d8839..61044d846df 100644 --- a/.changeset/oauth-application-token-exchange.md +++ b/.changeset/oauth-application-token-exchange.md @@ -2,4 +2,4 @@ '@clerk/backend': minor --- -Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass the application ID, its client secret, and the subject token to receive a new token issued by the application's token endpoint, for example to call a downstream API on a user's behalf. If the token endpoint rejects the exchange, the method throws an `OAuthTokenExchangeError`, exported from `@clerk/backend/errors`, with the OAuth `error` code, `errorDescription`, and HTTP `status`. +Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass the application ID, its client secret (omitted for public applications), and the subject token to receive a new token issued by the application's token endpoint, for example to call a downstream API on a user's behalf. If the token endpoint rejects the exchange, the method throws an `OAuthTokenExchangeError`, exported from `@clerk/backend/errors`, with the OAuth `error` code, `errorDescription`, and HTTP `status`. diff --git a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts index 73893a53590..5547cc63042 100644 --- a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts +++ b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts @@ -227,6 +227,34 @@ describe('OAuthApplications', () => { expect(result.refreshToken).toBe('refresh'); }); + it('sends client_id in the body and no Authorization header for public clients', async () => { + useApplication(); + + let captured: { authorization: string | null; body: URLSearchParams } | undefined; + server.use( + http.post(tokenFetchUrl, async ({ request }) => { + captured = { + authorization: request.headers.get('Authorization'), + body: new URLSearchParams(await request.text()), + }; + return HttpResponse.json({ + access_token: 'exchanged_token', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + }); + }), + ); + + await createClient().oauthApplications.__experimental_exchangeToken({ + oauthApplicationId, + subjectToken: 'subject_token', + subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + }); + + expect(captured?.authorization).toBeNull(); + expect(captured?.body.get('client_id')).toBe(clientId); + }); + it.each([ ['unsupported_grant_type', 'The authorization grant type is not supported.'], ['invalid_grant', undefined], diff --git a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts index 52758316ba2..abb3ce9622d 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -55,8 +55,8 @@ export type OAuthTokenTypeIdentifier = export type ExchangeOAuthApplicationTokenParams = { /** The ID of the OAuth application performing the exchange. */ oauthApplicationId: string; - /** The client secret of the OAuth application, used to authenticate with the token endpoint. */ - clientSecret: string; + /** The client secret of the OAuth application, used to authenticate with the token endpoint. Omit it for public OAuth applications, which have no client secret. */ + clientSecret?: string; /** The token that represents the identity of the party on behalf of whom the request is being made. */ subjectToken: string; /** The type of `subjectToken`. */ @@ -302,17 +302,20 @@ export class OAuthApplicationsApi extends AbstractAPI { const application = await this.get(params.oauthApplicationId); - const credentials = `${formUrlEncode(application.clientId)}:${formUrlEncode(params.clientSecret)}`; + const body = buildTokenExchangeBody(params); + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + if (params.clientSecret) { + const credentials = `${formUrlEncode(application.clientId)}:${formUrlEncode(params.clientSecret)}`; + headers.Authorization = `Basic ${btoa(credentials)}`; + } else { + body.set('client_id', application.clientId); + } - const response = await runtime.fetch(application.tokenFetchUrl, { - method: 'POST', - headers: { - Accept: 'application/json', - Authorization: `Basic ${btoa(credentials)}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: buildTokenExchangeBody(params), - }); + const response = await runtime.fetch(application.tokenFetchUrl, { method: 'POST', headers, body }); const json = await response.json().catch(() => undefined); From 20daa4c930530aea0ff928281cc70d4ee0ec13e5 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Nov 2026 18:05:29 -0600 Subject: [PATCH 04/14] refactor(backend): align OAuth token exchange helpers with endpoint conventions --- .../src/api/endpoints/OAuthApplicationsApi.ts | 109 ++++++++++-------- 1 file changed, 59 insertions(+), 50 deletions(-) diff --git a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts index abb3ce9622d..e72666fb56a 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -1,4 +1,4 @@ -import type { ClerkPaginationRequest } from '@clerk/shared/types'; +import type { Autocomplete, ClerkPaginationRequest } from '@clerk/shared/types'; import { OAuthTokenExchangeError } from '../../errors'; import { runtime } from '../../runtime'; @@ -42,12 +42,12 @@ export type RevokeOAuthApplicationTokenParams = { /** * A token type identifier as defined in [RFC 8693 section 3](https://www.rfc-editor.org/rfc/rfc8693#section-3). */ -export type OAuthTokenTypeIdentifier = +export type OAuthTokenTypeIdentifier = Autocomplete< | 'urn:ietf:params:oauth:token-type:access_token' | 'urn:ietf:params:oauth:token-type:refresh_token' | 'urn:ietf:params:oauth:token-type:id_token' | 'urn:ietf:params:oauth:token-type:jwt' - | (string & {}); +>; /** * The parameters for performing an [OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693) for an OAuth application. @@ -93,22 +93,45 @@ export type ExchangeOAuthApplicationTokenResult = { refreshToken?: string; }; -const TOKEN_EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'; +/** @generateWithEmptyComment */ +export type GetOAuthApplicationListParams = ClerkPaginationRequest<{ + /** + * Filters by a case-insensitive substring of the application name or an exact client ID. + */ + nameQuery?: string; + /** + * Returns OAuth applications in a particular order. Prefix a value with `+` to sort in ascending order, or `-` to sort in descending order. Defaults to `-created_at`. + */ + orderBy?: WithSign<'name' | 'created_at'>; +}>; + +type TokenExchangeResponseJSON = { + access_token: string; + issued_token_type: string; + token_type: string; + expires_in?: number; + scope?: string; + refresh_token?: string; +}; -const formUrlEncode = (value: string) => new URLSearchParams({ v: value }).toString().slice(2); +const tokenExchangeGrantType = 'urn:ietf:params:oauth:grant-type:token-exchange'; -const appendAll = (body: URLSearchParams, key: string, value: string | string[] | undefined) => { +function formUrlEncode(value: string) { + return new URLSearchParams({ v: value }).toString().slice(2); +} + +function appendAll(body: URLSearchParams, key: string, value: string | string[] | undefined) { if (value === undefined) { return; } for (const v of Array.isArray(value) ? value : [value]) { body.append(key, v); } -}; +} -const buildTokenExchangeBody = (params: ExchangeOAuthApplicationTokenParams) => { +function tokenExchangeBody(params: ExchangeOAuthApplicationTokenParams) { const body = new URLSearchParams({ - grant_type: TOKEN_EXCHANGE_GRANT_TYPE, + grant_type: tokenExchangeGrantType, subject_token: params.subjectToken, subject_token_type: params.subjectTokenType, }); @@ -121,35 +144,33 @@ const buildTokenExchangeBody = (params: ExchangeOAuthApplicationTokenParams) => body.set('scope', params.scope.join(' ')); } return body; -}; - -type TokenExchangeResponseJSON = { - access_token: string; - issued_token_type: string; - token_type: string; - expires_in?: number; - scope?: string; - refresh_token?: string; -}; +} -const toTokenExchangeResult = (json: TokenExchangeResponseJSON): ExchangeOAuthApplicationTokenResult => ({ - accessToken: json.access_token, - issuedTokenType: json.issued_token_type, - tokenType: json.token_type, - expiresIn: json.expires_in, - scope: json.scope ? json.scope.split(' ') : undefined, - refreshToken: json.refresh_token, -}); +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} -const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; +function isTokenExchangeResponse(value: unknown): value is TokenExchangeResponseJSON { + return ( + isRecord(value) && + typeof value.access_token === 'string' && + typeof value.issued_token_type === 'string' && + typeof value.token_type === 'string' + ); +} -const isTokenExchangeResponse = (value: unknown): value is TokenExchangeResponseJSON => - isRecord(value) && - typeof value.access_token === 'string' && - typeof value.issued_token_type === 'string' && - typeof value.token_type === 'string'; +function tokenExchangeResult(json: TokenExchangeResponseJSON): ExchangeOAuthApplicationTokenResult { + return { + accessToken: json.access_token, + issuedTokenType: json.issued_token_type, + tokenType: json.token_type, + expiresIn: json.expires_in, + scope: json.scope ? json.scope.split(' ') : undefined, + refreshToken: json.refresh_token, + }; +} -const toTokenExchangeError = (json: unknown, status: number) => { +function tokenExchangeError(json: unknown, status: number) { if (!isRecord(json) || typeof json.error !== 'string') { return new OAuthTokenExchangeError({ error: 'invalid_response', status }); } @@ -158,19 +179,7 @@ const toTokenExchangeError = (json: unknown, status: number) => { errorDescription: typeof json.error_description === 'string' ? json.error_description : undefined, status, }); -}; - -/** @generateWithEmptyComment */ -export type GetOAuthApplicationListParams = ClerkPaginationRequest<{ - /** - * Filters by a case-insensitive substring of the application name or an exact client ID. - */ - nameQuery?: string; - /** - * Returns OAuth applications in a particular order. Prefix a value with `+` to sort in ascending order, or `-` to sort in descending order. Defaults to `-created_at`. - */ - orderBy?: WithSign<'name' | 'created_at'>; -}>; +} /** @generateWithEmptyComment */ export class OAuthApplicationsApi extends AbstractAPI { @@ -302,7 +311,7 @@ export class OAuthApplicationsApi extends AbstractAPI { const application = await this.get(params.oauthApplicationId); - const body = buildTokenExchangeBody(params); + const body = tokenExchangeBody(params); const headers: Record = { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', @@ -320,13 +329,13 @@ export class OAuthApplicationsApi extends AbstractAPI { const json = await response.json().catch(() => undefined); if (!response.ok) { - throw toTokenExchangeError(json, response.status); + throw tokenExchangeError(json, response.status); } if (!isTokenExchangeResponse(json)) { throw new OAuthTokenExchangeError({ error: 'invalid_response', status: response.status }); } - return toTokenExchangeResult(json); + return tokenExchangeResult(json); } } From 6466a61173d564db041b802e120739dbc052f0fc Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Nov 2026 18:07:23 -0600 Subject: [PATCH 05/14] refactor(backend): base OAuthTokenExchangeError on ClerkError --- .changeset/oauth-application-token-exchange.md | 2 +- .../api/__tests__/OAuthApplicationsApi.test.ts | 12 +++++++++++- packages/backend/src/errors.ts | 16 +++++++++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.changeset/oauth-application-token-exchange.md b/.changeset/oauth-application-token-exchange.md index 61044d846df..ec1c973fc5a 100644 --- a/.changeset/oauth-application-token-exchange.md +++ b/.changeset/oauth-application-token-exchange.md @@ -2,4 +2,4 @@ '@clerk/backend': minor --- -Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass the application ID, its client secret (omitted for public applications), and the subject token to receive a new token issued by the application's token endpoint, for example to call a downstream API on a user's behalf. If the token endpoint rejects the exchange, the method throws an `OAuthTokenExchangeError`, exported from `@clerk/backend/errors`, with the OAuth `error` code, `errorDescription`, and HTTP `status`. +Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass the application ID, its client secret (omitted for public applications), and the subject token to receive a new token issued by the application's token endpoint, for example to call a downstream API on a user's behalf. If the token endpoint rejects the exchange, the method throws an `OAuthTokenExchangeError`, exported from `@clerk/backend/errors`, with the OAuth error code in `code` (also available as `error`), `errorDescription`, and HTTP `status`. diff --git a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts index 5547cc63042..17e05ede0dd 100644 --- a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts +++ b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts @@ -1,3 +1,4 @@ +import { ClerkError } from '@clerk/shared/error'; import { http, HttpResponse } from 'msw'; import { describe, expect, it } from 'vitest'; @@ -278,7 +279,16 @@ describe('OAuthApplications', () => { }); await expect(promise).rejects.toBeInstanceOf(OAuthTokenExchangeError); - await expect(promise).rejects.toMatchObject({ error, errorDescription, status: 400 }); + await expect(promise).rejects.toBeInstanceOf(ClerkError); + await expect(promise).rejects.toMatchObject({ + code: error, + error, + errorDescription, + status: 400, + clerkError: true, + name: 'OAuthTokenExchangeError', + message: errorDescription ? `${error}: ${errorDescription}` : error, + }); }); it('throws an invalid_response error when the error body is not JSON', async () => { diff --git a/packages/backend/src/errors.ts b/packages/backend/src/errors.ts index 744e165e149..d87a9b8df5b 100644 --- a/packages/backend/src/errors.ts +++ b/packages/backend/src/errors.ts @@ -118,17 +118,23 @@ export class MachineTokenVerificationError extends ClerkError { } } -export class OAuthTokenExchangeError extends Error { - readonly error: string; +export class OAuthTokenExchangeError extends ClerkError { + static kind = 'OAuthTokenExchangeError'; readonly errorDescription?: string; readonly status?: number; constructor({ error, errorDescription, status }: { error: string; errorDescription?: string; status?: number }) { - super(errorDescription ? `${error}: ${errorDescription}` : error); + super({ message: errorDescription ? `${error}: ${errorDescription}` : error, code: error }); Object.setPrototypeOf(this, OAuthTokenExchangeError.prototype); - this.name = 'OAuthTokenExchangeError'; - this.error = error; this.errorDescription = errorDescription; this.status = status; } + + get error() { + return this.code; + } + + protected static override formatMessage(_name: string, msg: string, _code: string, _docsUrl: string | undefined) { + return msg; + } } From 2731e25ba9d5a8c26d16000cbf276860e5f2478b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Nov 2026 18:13:42 -0600 Subject: [PATCH 06/14] fix(backend): require actorToken and actorTokenType together in OAuth token exchange --- .../__tests__/OAuthApplicationsApi.test.ts | 67 +++++++++++++------ .../src/api/endpoints/OAuthApplicationsApi.ts | 23 ++++--- 2 files changed, 61 insertions(+), 29 deletions(-) diff --git a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts index 17e05ede0dd..5f59ca1ef8b 100644 --- a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts +++ b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { OAuthTokenExchangeError } from '../../errors'; import { server, validateHeaders } from '../../mock-server'; +import type { ExchangeOAuthApplicationTokenParams } from '../endpoints/OAuthApplicationsApi'; import { createBackendApiClient } from '../factory'; describe('OAuthApplications', () => { @@ -228,6 +229,29 @@ describe('OAuthApplications', () => { expect(result.refreshToken).toBe('refresh'); }); + it('ignores empty entries in a returned scope', async () => { + useApplication(); + server.use( + http.post(tokenFetchUrl, () => + HttpResponse.json({ + access_token: 'exchanged_token', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + scope: ' invoices:read invoices:write ', + }), + ), + ); + + const result = await createClient().oauthApplications.__experimental_exchangeToken({ + oauthApplicationId, + clientSecret, + subjectToken: 'subject_token', + subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + }); + + expect(result.scope).toEqual(['invoices:read', 'invoices:write']); + }); + it('sends client_id in the body and no Authorization header for public clients', async () => { useApplication(); @@ -319,26 +343,29 @@ describe('OAuthApplications', () => { ).rejects.toMatchObject({ error: 'invalid_response', status: 200 }); }); - it('rejects actorTokenType without actorToken before making any request', async () => { - let requested = false; - server.use( - http.all('*', () => { - requested = true; - return HttpResponse.json({}); - }), - ); - - await expect( - createClient().oauthApplications.__experimental_exchangeToken({ - oauthApplicationId, - clientSecret, - subjectToken: 'subject_token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', - actorTokenType: 'urn:ietf:params:oauth:token-type:jwt', - }), - ).rejects.toThrow('`actorTokenType` requires `actorToken`.'); - expect(requested).toBe(false); - }); + it.each([{ actorTokenType: 'urn:ietf:params:oauth:token-type:jwt' }, { actorToken: 'actor_token' }])( + 'rejects an unpaired actor parameter before making any request: %o', + async actor => { + let requested = false; + server.use( + http.all('*', () => { + requested = true; + return HttpResponse.json({}); + }), + ); + + await expect( + createClient().oauthApplications.__experimental_exchangeToken({ + oauthApplicationId, + clientSecret, + subjectToken: 'subject_token', + subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + ...actor, + } as unknown as ExchangeOAuthApplicationTokenParams), + ).rejects.toThrow('`actorToken` and `actorTokenType` must be provided together.'); + expect(requested).toBe(false); + }, + ); it('throws when the OAuth application ID is missing', async () => { await expect( diff --git a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts index e72666fb56a..814c80fd772 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -61,10 +61,6 @@ export type ExchangeOAuthApplicationTokenParams = { subjectToken: string; /** The type of `subjectToken`. */ subjectTokenType: OAuthTokenTypeIdentifier; - /** The token that represents the identity of the acting party, for delegation. */ - actorToken?: string; - /** The type of `actorToken`. Required when `actorToken` is provided. */ - actorTokenType?: OAuthTokenTypeIdentifier; /** The type of token being requested. */ requestedTokenType?: OAuthTokenTypeIdentifier; /** The logical name or names of the target service where the issued token will be used. */ @@ -73,7 +69,15 @@ export type ExchangeOAuthApplicationTokenParams = { resource?: string | string[]; /** The scopes requested for the issued token. */ scope?: string[]; -}; +} & ( + | { + /** The token that represents the identity of the acting party, for delegation. */ + actorToken: string; + /** The type of `actorToken`. */ + actorTokenType: OAuthTokenTypeIdentifier; + } + | { actorToken?: never; actorTokenType?: never } +); /** * The result of a successful [OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693#section-2.2.1). @@ -165,7 +169,7 @@ function tokenExchangeResult(json: TokenExchangeResponseJSON): ExchangeOAuthAppl issuedTokenType: json.issued_token_type, tokenType: json.token_type, expiresIn: json.expires_in, - scope: json.scope ? json.scope.split(' ') : undefined, + scope: json.scope ? json.scope.split(' ').filter(Boolean) : undefined, refreshToken: json.refresh_token, }; } @@ -290,12 +294,13 @@ export class OAuthApplicationsApi extends AbstractAPI { * @param params - The parameters for the token exchange. * @returns The issued token and its metadata. * @throws `OAuthTokenExchangeError` if the token endpoint rejects the exchange or returns an invalid response. + * @throws `Error` if only one of `actorToken` and `actorTokenType` is provided. * * @example * ```ts * const { accessToken } = await clerkClient.oauthApplications.__experimental_exchangeToken({ * oauthApplicationId: 'oauthapp_123', - * clientSecret: process.env.OAUTH_CLIENT_SECRET, + * clientSecret: process.env.OAUTH_CLIENT_SECRET!, * subjectToken, * subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', * audience: 'https://api.example.com', @@ -305,8 +310,8 @@ export class OAuthApplicationsApi extends AbstractAPI { public async __experimental_exchangeToken( params: ExchangeOAuthApplicationTokenParams, ): Promise { - if (params.actorTokenType && !params.actorToken) { - throw new Error('`actorTokenType` requires `actorToken`.'); + if (!params.actorToken !== !params.actorTokenType) { + throw new Error('`actorToken` and `actorTokenType` must be provided together.'); } const application = await this.get(params.oauthApplicationId); From c430897e059f4a69a738ca3b79a36ae06d4dfe6e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Nov 2026 18:16:54 -0600 Subject: [PATCH 07/14] refactor(backend): narrow experimental OAuth token exchange params to what Clerk supports Drop subjectTokenType, requestedTokenType, actorToken, actorTokenType, and audience, and require a single resource. The SDK always sends the access-token subject_token_type that RFC 8693 section 2.1 requires. --- .../oauth-application-token-exchange.md | 2 +- .../__tests__/OAuthApplicationsApi.test.ts | 52 ++++--------------- .../src/api/endpoints/OAuthApplicationsApi.ts | 50 ++++-------------- 3 files changed, 19 insertions(+), 85 deletions(-) diff --git a/.changeset/oauth-application-token-exchange.md b/.changeset/oauth-application-token-exchange.md index ec1c973fc5a..258df887a38 100644 --- a/.changeset/oauth-application-token-exchange.md +++ b/.changeset/oauth-application-token-exchange.md @@ -2,4 +2,4 @@ '@clerk/backend': minor --- -Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass the application ID, its client secret (omitted for public applications), and the subject token to receive a new token issued by the application's token endpoint, for example to call a downstream API on a user's behalf. If the token endpoint rejects the exchange, the method throws an `OAuthTokenExchangeError`, exported from `@clerk/backend/errors`, with the OAuth error code in `code` (also available as `error`), `errorDescription`, and HTTP `status`. +Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass the application ID, its client secret (omitted for public applications), a user's OAuth access token, and the target `resource` to receive a new access token for that resource, for example to call a downstream API on a user's behalf. If the token endpoint rejects the exchange, the method throws an `OAuthTokenExchangeError`, exported from `@clerk/backend/errors`, with the OAuth error code in `code` (also available as `error`), `errorDescription`, and HTTP `status`. diff --git a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts index 5f59ca1ef8b..ca685cc3b3e 100644 --- a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts +++ b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from 'vitest'; import { OAuthTokenExchangeError } from '../../errors'; import { server, validateHeaders } from '../../mock-server'; -import type { ExchangeOAuthApplicationTokenParams } from '../endpoints/OAuthApplicationsApi'; import { createBackendApiClient } from '../factory'; describe('OAuthApplications', () => { @@ -142,11 +141,6 @@ describe('OAuthApplications', () => { oauthApplicationId, clientSecret, subjectToken: 'subject_token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', - actorToken: 'actor_token', - actorTokenType: 'urn:ietf:params:oauth:token-type:jwt', - requestedTokenType: 'urn:ietf:params:oauth:token-type:access_token', - audience: ['https://a.example.test', 'https://b.example.test'], resource: 'https://r.example.test', scope: ['invoices:read', 'invoices:write'], }); @@ -167,10 +161,6 @@ describe('OAuthApplications', () => { expect(body?.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:token-exchange'); expect(body?.get('subject_token')).toBe('subject_token'); expect(body?.get('subject_token_type')).toBe('urn:ietf:params:oauth:token-type:access_token'); - expect(body?.get('actor_token')).toBe('actor_token'); - expect(body?.get('actor_token_type')).toBe('urn:ietf:params:oauth:token-type:jwt'); - expect(body?.get('requested_token_type')).toBe('urn:ietf:params:oauth:token-type:access_token'); - expect(body?.getAll('audience')).toEqual(['https://a.example.test', 'https://b.example.test']); expect(body?.getAll('resource')).toEqual(['https://r.example.test']); expect(body?.get('scope')).toBe('invoices:read invoices:write'); expect(body?.has('client_id')).toBe(false); @@ -195,7 +185,7 @@ describe('OAuthApplications', () => { oauthApplicationId, clientSecret, subjectToken: 'subject_token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + resource: 'https://r.example.test', }); expect(authorization).toBe(`Basic ${btoa('client+id%3Awith%2Breserved:secret+value%3A%2B%2F%3D')}`); @@ -221,10 +211,10 @@ describe('OAuthApplications', () => { oauthApplicationId, clientSecret, subjectToken: 'subject_token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + resource: 'https://r.example.test', }); - expect([...(body?.keys() ?? [])]).toEqual(['grant_type', 'subject_token', 'subject_token_type']); + expect([...(body?.keys() ?? [])]).toEqual(['grant_type', 'subject_token', 'subject_token_type', 'resource']); expect(result.scope).toBeUndefined(); expect(result.refreshToken).toBe('refresh'); }); @@ -246,7 +236,7 @@ describe('OAuthApplications', () => { oauthApplicationId, clientSecret, subjectToken: 'subject_token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + resource: 'https://r.example.test', }); expect(result.scope).toEqual(['invoices:read', 'invoices:write']); @@ -273,7 +263,7 @@ describe('OAuthApplications', () => { await createClient().oauthApplications.__experimental_exchangeToken({ oauthApplicationId, subjectToken: 'subject_token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + resource: 'https://r.example.test', }); expect(captured?.authorization).toBeNull(); @@ -299,7 +289,7 @@ describe('OAuthApplications', () => { oauthApplicationId, clientSecret, subjectToken: 'subject_token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + resource: 'https://r.example.test', }); await expect(promise).rejects.toBeInstanceOf(OAuthTokenExchangeError); @@ -324,7 +314,7 @@ describe('OAuthApplications', () => { oauthApplicationId, clientSecret, subjectToken: 'subject_token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + resource: 'https://r.example.test', }), ).rejects.toMatchObject({ error: 'invalid_response', errorDescription: undefined, status: 502 }); }); @@ -338,42 +328,18 @@ describe('OAuthApplications', () => { oauthApplicationId, clientSecret, subjectToken: 'subject_token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + resource: 'https://r.example.test', }), ).rejects.toMatchObject({ error: 'invalid_response', status: 200 }); }); - it.each([{ actorTokenType: 'urn:ietf:params:oauth:token-type:jwt' }, { actorToken: 'actor_token' }])( - 'rejects an unpaired actor parameter before making any request: %o', - async actor => { - let requested = false; - server.use( - http.all('*', () => { - requested = true; - return HttpResponse.json({}); - }), - ); - - await expect( - createClient().oauthApplications.__experimental_exchangeToken({ - oauthApplicationId, - clientSecret, - subjectToken: 'subject_token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', - ...actor, - } as unknown as ExchangeOAuthApplicationTokenParams), - ).rejects.toThrow('`actorToken` and `actorTokenType` must be provided together.'); - expect(requested).toBe(false); - }, - ); - it('throws when the OAuth application ID is missing', async () => { await expect( createClient().oauthApplications.__experimental_exchangeToken({ oauthApplicationId: '', clientSecret, subjectToken: 'subject_token', - subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + resource: 'https://r.example.test', }), ).rejects.toThrow('A valid resource ID is required.'); }); diff --git a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts index 814c80fd772..af3c4f8bc26 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -57,27 +57,13 @@ export type ExchangeOAuthApplicationTokenParams = { oauthApplicationId: string; /** The client secret of the OAuth application, used to authenticate with the token endpoint. Omit it for public OAuth applications, which have no client secret. */ clientSecret?: string; - /** The token that represents the identity of the party on behalf of whom the request is being made. */ + /** The OAuth access token that represents the user on whose behalf the request is being made. */ subjectToken: string; - /** The type of `subjectToken`. */ - subjectTokenType: OAuthTokenTypeIdentifier; - /** The type of token being requested. */ - requestedTokenType?: OAuthTokenTypeIdentifier; - /** The logical name or names of the target service where the issued token will be used. */ - audience?: string | string[]; - /** The URI or URIs of the target service or resource where the issued token will be used. */ - resource?: string | string[]; + /** The URI of the target resource where the issued token will be used. */ + resource: string; /** The scopes requested for the issued token. */ scope?: string[]; -} & ( - | { - /** The token that represents the identity of the acting party, for delegation. */ - actorToken: string; - /** The type of `actorToken`. */ - actorTokenType: OAuthTokenTypeIdentifier; - } - | { actorToken?: never; actorTokenType?: never } -); +}; /** * The result of a successful [OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693#section-2.2.1). @@ -119,31 +105,19 @@ type TokenExchangeResponseJSON = { }; const tokenExchangeGrantType = 'urn:ietf:params:oauth:grant-type:token-exchange'; +const accessTokenType = 'urn:ietf:params:oauth:token-type:access_token'; function formUrlEncode(value: string) { return new URLSearchParams({ v: value }).toString().slice(2); } -function appendAll(body: URLSearchParams, key: string, value: string | string[] | undefined) { - if (value === undefined) { - return; - } - for (const v of Array.isArray(value) ? value : [value]) { - body.append(key, v); - } -} - function tokenExchangeBody(params: ExchangeOAuthApplicationTokenParams) { const body = new URLSearchParams({ grant_type: tokenExchangeGrantType, subject_token: params.subjectToken, - subject_token_type: params.subjectTokenType, + subject_token_type: accessTokenType, + resource: params.resource, }); - appendAll(body, 'actor_token', params.actorToken); - appendAll(body, 'actor_token_type', params.actorTokenType); - appendAll(body, 'requested_token_type', params.requestedTokenType); - appendAll(body, 'audience', params.audience); - appendAll(body, 'resource', params.resource); if (params.scope?.length) { body.set('scope', params.scope.join(' ')); } @@ -289,12 +263,11 @@ export class OAuthApplicationsApi extends AbstractAPI { /** * @experimental This is an experimental API for OAuth 2.0 Token Exchange and is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version to avoid breaking changes. * - * Performs an [OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693) for the given [`OAuthApplication`](/docs/reference/backend/types/backend-oauth-application), exchanging a subject token for a new token issued by the application's token endpoint. + * Performs an [OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693) for the given [`OAuthApplication`](/docs/reference/backend/types/backend-oauth-application), exchanging a user's OAuth access token for a new access token for the target resource, issued by the application's token endpoint. * * @param params - The parameters for the token exchange. * @returns The issued token and its metadata. * @throws `OAuthTokenExchangeError` if the token endpoint rejects the exchange or returns an invalid response. - * @throws `Error` if only one of `actorToken` and `actorTokenType` is provided. * * @example * ```ts @@ -302,18 +275,13 @@ export class OAuthApplicationsApi extends AbstractAPI { * oauthApplicationId: 'oauthapp_123', * clientSecret: process.env.OAUTH_CLIENT_SECRET!, * subjectToken, - * subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', - * audience: 'https://api.example.com', + * resource: 'https://api.example.com', * }); * ``` */ public async __experimental_exchangeToken( params: ExchangeOAuthApplicationTokenParams, ): Promise { - if (!params.actorToken !== !params.actorTokenType) { - throw new Error('`actorToken` and `actorTokenType` must be provided together.'); - } - const application = await this.get(params.oauthApplicationId); const body = tokenExchangeBody(params); From bb80008c15e23d5074b50b88fc4be4dcc6dde664 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Nov 2026 18:21:35 -0600 Subject: [PATCH 08/14] refactor(backend): require clientSecret for experimental OAuth token exchange Clerk only permits confidential OAuth applications to perform token exchange, so drop the public-client branch that sent client_id in the body. --- .../oauth-application-token-exchange.md | 2 +- .../__tests__/OAuthApplicationsApi.test.ts | 28 ------------------- .../src/api/endpoints/OAuthApplicationsApi.ts | 15 ++++------ 3 files changed, 6 insertions(+), 39 deletions(-) diff --git a/.changeset/oauth-application-token-exchange.md b/.changeset/oauth-application-token-exchange.md index 258df887a38..ccd457b0ef1 100644 --- a/.changeset/oauth-application-token-exchange.md +++ b/.changeset/oauth-application-token-exchange.md @@ -2,4 +2,4 @@ '@clerk/backend': minor --- -Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass the application ID, its client secret (omitted for public applications), a user's OAuth access token, and the target `resource` to receive a new access token for that resource, for example to call a downstream API on a user's behalf. If the token endpoint rejects the exchange, the method throws an `OAuthTokenExchangeError`, exported from `@clerk/backend/errors`, with the OAuth error code in `code` (also available as `error`), `errorDescription`, and HTTP `status`. +Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass the application ID, its client secret, a user's OAuth access token, and the target `resource` to receive a new access token for that resource, for example to call a downstream API on a user's behalf. If the token endpoint rejects the exchange, the method throws an `OAuthTokenExchangeError`, exported from `@clerk/backend/errors`, with the OAuth error code in `code` (also available as `error`), `errorDescription`, and HTTP `status`. diff --git a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts index ca685cc3b3e..f69c8d06d08 100644 --- a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts +++ b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts @@ -242,34 +242,6 @@ describe('OAuthApplications', () => { expect(result.scope).toEqual(['invoices:read', 'invoices:write']); }); - it('sends client_id in the body and no Authorization header for public clients', async () => { - useApplication(); - - let captured: { authorization: string | null; body: URLSearchParams } | undefined; - server.use( - http.post(tokenFetchUrl, async ({ request }) => { - captured = { - authorization: request.headers.get('Authorization'), - body: new URLSearchParams(await request.text()), - }; - return HttpResponse.json({ - access_token: 'exchanged_token', - issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', - token_type: 'Bearer', - }); - }), - ); - - await createClient().oauthApplications.__experimental_exchangeToken({ - oauthApplicationId, - subjectToken: 'subject_token', - resource: 'https://r.example.test', - }); - - expect(captured?.authorization).toBeNull(); - expect(captured?.body.get('client_id')).toBe(clientId); - }); - it.each([ ['unsupported_grant_type', 'The authorization grant type is not supported.'], ['invalid_grant', undefined], diff --git a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts index af3c4f8bc26..462bd2026e8 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -55,8 +55,8 @@ export type OAuthTokenTypeIdentifier = Autocomplete< export type ExchangeOAuthApplicationTokenParams = { /** The ID of the OAuth application performing the exchange. */ oauthApplicationId: string; - /** The client secret of the OAuth application, used to authenticate with the token endpoint. Omit it for public OAuth applications, which have no client secret. */ - clientSecret?: string; + /** The client secret of the OAuth application, used to authenticate with the token endpoint. */ + clientSecret: string; /** The OAuth access token that represents the user on whose behalf the request is being made. */ subjectToken: string; /** The URI of the target resource where the issued token will be used. */ @@ -284,19 +284,14 @@ export class OAuthApplicationsApi extends AbstractAPI { ): Promise { const application = await this.get(params.oauthApplicationId); + const credentials = `${formUrlEncode(application.clientId)}:${formUrlEncode(params.clientSecret)}`; const body = tokenExchangeBody(params); - const headers: Record = { + const headers = { Accept: 'application/json', + Authorization: `Basic ${btoa(credentials)}`, 'Content-Type': 'application/x-www-form-urlencoded', }; - if (params.clientSecret) { - const credentials = `${formUrlEncode(application.clientId)}:${formUrlEncode(params.clientSecret)}`; - headers.Authorization = `Basic ${btoa(credentials)}`; - } else { - body.set('client_id', application.clientId); - } - const response = await runtime.fetch(application.tokenFetchUrl, { method: 'POST', headers, body }); const json = await response.json().catch(() => undefined); From 78d1ddf9b3396d99cf78236a0428dc14cfde41ae Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Nov 2026 19:03:23 -0600 Subject: [PATCH 09/14] refactor(backend): exchange OAuth tokens with client credentials and publishable key Take clientId and publishableKey (plus optional proxyUrl) per call and derive the Frontend API token endpoint from them, instead of loading the OAuth application through the Backend API. The exchange no longer requires the instance secret key. --- .../oauth-application-token-exchange.md | 2 +- .../__tests__/OAuthApplicationsApi.test.ts | 135 ++++++++++-------- .../src/api/endpoints/OAuthApplicationsApi.ts | 36 ++++- 3 files changed, 105 insertions(+), 68 deletions(-) diff --git a/.changeset/oauth-application-token-exchange.md b/.changeset/oauth-application-token-exchange.md index ccd457b0ef1..7ce9b371ec0 100644 --- a/.changeset/oauth-application-token-exchange.md +++ b/.changeset/oauth-application-token-exchange.md @@ -2,4 +2,4 @@ '@clerk/backend': minor --- -Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass the application ID, its client secret, a user's OAuth access token, and the target `resource` to receive a new access token for that resource, for example to call a downstream API on a user's behalf. If the token endpoint rejects the exchange, the method throws an `OAuthTokenExchangeError`, exported from `@clerk/backend/errors`, with the OAuth error code in `code` (also available as `error`), `errorDescription`, and HTTP `status`. +Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass your publishable key, the application's client ID and client secret, a user's OAuth access token, and the target `resource` to receive a new access token for that resource, for example to call a downstream API on a user's behalf. The request goes to your instance's token endpoint, derived from the publishable key (or from `proxyUrl` when set), so it does not require a secret key. If the token endpoint rejects the exchange, the method throws an `OAuthTokenExchangeError`, exported from `@clerk/backend/errors`, with the OAuth error code in `code` (also available as `error`), `errorDescription`, and HTTP `status`. diff --git a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts index f69c8d06d08..4183737e28e 100644 --- a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts +++ b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts @@ -1,4 +1,5 @@ import { ClerkError } from '@clerk/shared/error'; +import { buildPublishableKey } from '@clerk/shared/keys'; import { http, HttpResponse } from 'msw'; import { describe, expect, it } from 'vitest'; @@ -83,46 +84,18 @@ describe('OAuthApplications', () => { const clientId = 'client id:with+reserved'; const clientSecret = 'secret value:+/='; - const mockOAuthApplication = { - object: 'oauth_application', - id: oauthApplicationId, - instance_id: 'ins_xxxxx', - name: 'Example', - client_id: clientId, - client_uri: null, - client_image_url: null, - dynamically_registered: false, - consent_screen_enabled: true, - pkce_required: false, - public: false, - scopes: 'profile email', - redirect_uris: [], - authorize_url: 'https://clerk.example.test/oauth/authorize', - token_fetch_url: tokenFetchUrl, - user_info_url: 'https://clerk.example.test/oauth/userinfo', - discovery_url: 'https://clerk.example.test/.well-known/openid-configuration', - token_introspection_url: 'https://clerk.example.test/oauth/token_info', - created_at: 1, - updated_at: 1, - }; - - const createClient = () => - createBackendApiClient({ - apiUrl: 'https://api.clerk.test', - secretKey: 'sk_xxxxx', - }); + const publishableKey = buildPublishableKey('clerk.example.test'); - const useApplication = () => - server.use( - http.get( - `https://api.clerk.test/v1/oauth_applications/${oauthApplicationId}`, - validateHeaders(() => HttpResponse.json(mockOAuthApplication)), - ), - ); + const createClient = () => createBackendApiClient({ apiUrl: 'https://api.clerk.test' }); - it('exchanges a subject token at the application token endpoint', async () => { - useApplication(); + const tokenResponse = () => + HttpResponse.json({ + access_token: 'exchanged_token', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + }); + it('exchanges a subject token at the application token endpoint', async () => { let captured: { headers: Headers; body: URLSearchParams } | undefined; server.use( http.post(tokenFetchUrl, async ({ request }) => { @@ -138,7 +111,8 @@ describe('OAuthApplications', () => { ); const result = await createClient().oauthApplications.__experimental_exchangeToken({ - oauthApplicationId, + publishableKey, + clientId, clientSecret, subjectToken: 'subject_token', resource: 'https://r.example.test', @@ -167,22 +141,17 @@ describe('OAuthApplications', () => { }); it('authenticates with form-urlencoded client credentials over HTTP Basic', async () => { - useApplication(); - let authorization: string | null = null; server.use( http.post(tokenFetchUrl, ({ request }) => { authorization = request.headers.get('Authorization'); - return HttpResponse.json({ - access_token: 'exchanged_token', - issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', - token_type: 'Bearer', - }); + return tokenResponse(); }), ); await createClient().oauthApplications.__experimental_exchangeToken({ - oauthApplicationId, + publishableKey, + clientId, clientSecret, subjectToken: 'subject_token', resource: 'https://r.example.test', @@ -192,8 +161,6 @@ describe('OAuthApplications', () => { }); it('omits optional parameters that are not provided', async () => { - useApplication(); - let body: URLSearchParams | undefined; server.use( http.post(tokenFetchUrl, async ({ request }) => { @@ -208,7 +175,8 @@ describe('OAuthApplications', () => { ); const result = await createClient().oauthApplications.__experimental_exchangeToken({ - oauthApplicationId, + publishableKey, + clientId, clientSecret, subjectToken: 'subject_token', resource: 'https://r.example.test', @@ -220,7 +188,6 @@ describe('OAuthApplications', () => { }); it('ignores empty entries in a returned scope', async () => { - useApplication(); server.use( http.post(tokenFetchUrl, () => HttpResponse.json({ @@ -233,7 +200,8 @@ describe('OAuthApplications', () => { ); const result = await createClient().oauthApplications.__experimental_exchangeToken({ - oauthApplicationId, + publishableKey, + clientId, clientSecret, subjectToken: 'subject_token', resource: 'https://r.example.test', @@ -247,7 +215,6 @@ describe('OAuthApplications', () => { ['invalid_grant', undefined], ['invalid_target', 'Unknown audience.'], ])('throws OAuthTokenExchangeError for a %s error response', async (error, errorDescription) => { - useApplication(); server.use( http.post(tokenFetchUrl, () => HttpResponse.json( @@ -258,7 +225,8 @@ describe('OAuthApplications', () => { ); const promise = createClient().oauthApplications.__experimental_exchangeToken({ - oauthApplicationId, + publishableKey, + clientId, clientSecret, subjectToken: 'subject_token', resource: 'https://r.example.test', @@ -278,12 +246,12 @@ describe('OAuthApplications', () => { }); it('throws an invalid_response error when the error body is not JSON', async () => { - useApplication(); server.use(http.post(tokenFetchUrl, () => new HttpResponse('Bad Gateway', { status: 502 }))); await expect( createClient().oauthApplications.__experimental_exchangeToken({ - oauthApplicationId, + publishableKey, + clientId, clientSecret, subjectToken: 'subject_token', resource: 'https://r.example.test', @@ -292,12 +260,12 @@ describe('OAuthApplications', () => { }); it('throws an invalid_response error when a successful response is malformed', async () => { - useApplication(); server.use(http.post(tokenFetchUrl, () => HttpResponse.json({ token_type: 'Bearer' }))); await expect( createClient().oauthApplications.__experimental_exchangeToken({ - oauthApplicationId, + publishableKey, + clientId, clientSecret, subjectToken: 'subject_token', resource: 'https://r.example.test', @@ -305,15 +273,62 @@ describe('OAuthApplications', () => { ).rejects.toMatchObject({ error: 'invalid_response', status: 200 }); }); - it('throws when the OAuth application ID is missing', async () => { + it('sends the exchange to the proxy URL when one is configured', async () => { + let requested = false; + server.use( + http.post('https://example.test/__clerk/oauth/token', () => { + requested = true; + return tokenResponse(); + }), + ); + + await createClient().oauthApplications.__experimental_exchangeToken({ + publishableKey, + proxyUrl: 'https://example.test/__clerk/', + clientId, + clientSecret, + subjectToken: 'subject_token', + resource: 'https://r.example.test', + }); + + expect(requested).toBe(true); + }); + + it.each([ + ['the publishable key is missing', { publishableKey: '' }, 'Publishable key is missing.'], + ['the publishable key is invalid', { publishableKey: 'pk_invalid' }, 'Publishable key not valid'], + ['the proxy URL is relative', { publishableKey, proxyUrl: '/__clerk' }, '`proxyUrl` must be an absolute URL'], + ])('throws before making any request when %s', async (_, endpointParams, message) => { + let requested = false; + server.use( + http.all('*', () => { + requested = true; + return tokenResponse(); + }), + ); + await expect( createClient().oauthApplications.__experimental_exchangeToken({ - oauthApplicationId: '', + ...endpointParams, + clientId, clientSecret, subjectToken: 'subject_token', resource: 'https://r.example.test', }), - ).rejects.toThrow('A valid resource ID is required.'); + ).rejects.toThrow(message); + expect(requested).toBe(false); + }); + + it('throws when the client ID is missing', async () => { + await expect( + createClient().oauthApplications.__experimental_exchangeToken({ + publishableKey, + clientId: '', + clientSecret, + subjectToken: 'subject_token', + resource: 'https://r.example.test', + }), + ).rejects.toThrow('A valid client ID is required.'); }); }); }); diff --git a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts index 462bd2026e8..2a6a3b552d6 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -1,8 +1,10 @@ +import { isHttpOrHttps } from '@clerk/shared/proxy'; import type { Autocomplete, ClerkPaginationRequest } from '@clerk/shared/types'; import { OAuthTokenExchangeError } from '../../errors'; import { runtime } from '../../runtime'; import { joinPaths } from '../../util/path'; +import { parsePublishableKey } from '../../util/shared'; import type { DeletedObject } from '../resources'; import type { PaginatedResourceResponse } from '../resources/Deserializer'; import type { OAuthApplication } from '../resources/OAuthApplication'; @@ -53,8 +55,12 @@ export type OAuthTokenTypeIdentifier = Autocomplete< * The parameters for performing an [OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693) for an OAuth application. */ export type ExchangeOAuthApplicationTokenParams = { - /** The ID of the OAuth application performing the exchange. */ - oauthApplicationId: string; + /** The publishable key of the instance, used to derive its token endpoint. */ + publishableKey: string; + /** The absolute URL of your Frontend API proxy. When set, the exchange is sent through the proxy instead of the Frontend API host in `publishableKey`. */ + proxyUrl?: string; + /** The client ID of the OAuth application performing the exchange. */ + clientId: string; /** The client secret of the OAuth application, used to authenticate with the token endpoint. */ clientSecret: string; /** The OAuth access token that represents the user on whose behalf the request is being made. */ @@ -159,6 +165,17 @@ function tokenExchangeError(json: unknown, status: number) { }); } +function oauthTokenEndpoint({ publishableKey, proxyUrl }: ExchangeOAuthApplicationTokenParams) { + if (proxyUrl) { + if (!isHttpOrHttps(proxyUrl)) { + throw new Error('`proxyUrl` must be an absolute URL to perform an OAuth token exchange.'); + } + return `${proxyUrl.replace(/\/+$/, '')}/oauth/token`; + } + const { frontendApi } = parsePublishableKey(publishableKey, { fatal: true }); + return `https://${frontendApi}/oauth/token`; +} + /** @generateWithEmptyComment */ export class OAuthApplicationsApi extends AbstractAPI { /** @@ -263,16 +280,18 @@ export class OAuthApplicationsApi extends AbstractAPI { /** * @experimental This is an experimental API for OAuth 2.0 Token Exchange and is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version to avoid breaking changes. * - * Performs an [OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693) for the given [`OAuthApplication`](/docs/reference/backend/types/backend-oauth-application), exchanging a user's OAuth access token for a new access token for the target resource, issued by the application's token endpoint. + * Performs an [OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693) for an [`OAuthApplication`](/docs/reference/backend/types/backend-oauth-application), exchanging a user's OAuth access token for a new access token for the target resource. The request is sent to the instance's token endpoint, derived from `publishableKey`, or from `proxyUrl` when set, and authenticates with the application's client ID and client secret. The instance secret key is not required. * * @param params - The parameters for the token exchange. * @returns The issued token and its metadata. * @throws `OAuthTokenExchangeError` if the token endpoint rejects the exchange or returns an invalid response. + * @throws `Error` if `clientId` is empty, `publishableKey` is missing or invalid, or `proxyUrl` is not an absolute URL. * * @example * ```ts * const { accessToken } = await clerkClient.oauthApplications.__experimental_exchangeToken({ - * oauthApplicationId: 'oauthapp_123', + * publishableKey: process.env.CLERK_PUBLISHABLE_KEY!, + * clientId: process.env.OAUTH_CLIENT_ID!, * clientSecret: process.env.OAUTH_CLIENT_SECRET!, * subjectToken, * resource: 'https://api.example.com', @@ -282,9 +301,12 @@ export class OAuthApplicationsApi extends AbstractAPI { public async __experimental_exchangeToken( params: ExchangeOAuthApplicationTokenParams, ): Promise { - const application = await this.get(params.oauthApplicationId); + if (!params.clientId) { + throw new Error('A valid client ID is required.'); + } - const credentials = `${formUrlEncode(application.clientId)}:${formUrlEncode(params.clientSecret)}`; + const tokenEndpoint = oauthTokenEndpoint(params); + const credentials = `${formUrlEncode(params.clientId)}:${formUrlEncode(params.clientSecret)}`; const body = tokenExchangeBody(params); const headers = { Accept: 'application/json', @@ -292,7 +314,7 @@ export class OAuthApplicationsApi extends AbstractAPI { 'Content-Type': 'application/x-www-form-urlencoded', }; - const response = await runtime.fetch(application.tokenFetchUrl, { method: 'POST', headers, body }); + const response = await runtime.fetch(tokenEndpoint, { method: 'POST', headers, body }); const json = await response.json().catch(() => undefined); From 89ad6e8d3e7b0e7014c06e3f37147290f90a0f2c Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Nov 2026 19:19:52 -0600 Subject: [PATCH 10/14] fix(backend): reject malformed token-exchange responses Treat only HTTP 200 as a successful token-exchange response and require scope, expires_in and refresh_token to be absent or correctly typed, so malformed responses surface as OAuthTokenExchangeError invalid_response instead of a raw TypeError or wrongly typed result. --- .../__tests__/OAuthApplicationsApi.test.ts | 93 +++++++++++++++++++ .../src/api/endpoints/OAuthApplicationsApi.ts | 23 ++++- 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts index 4183737e28e..129c0d9a498 100644 --- a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts +++ b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts @@ -273,6 +273,99 @@ describe('OAuthApplications', () => { ).rejects.toMatchObject({ error: 'invalid_response', status: 200 }); }); + it.each([201, 202, 204])('throws an invalid_response error for a %i response', async status => { + server.use( + http.post(tokenFetchUrl, () => + status === 204 + ? new HttpResponse(null, { status }) + : HttpResponse.json( + { + access_token: 'exchanged_token', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + }, + { status }, + ), + ), + ); + + const promise = createClient().oauthApplications.__experimental_exchangeToken({ + publishableKey, + clientId, + clientSecret, + subjectToken: 'subject_token', + resource: 'https://r.example.test', + }); + + await expect(promise).rejects.toBeInstanceOf(OAuthTokenExchangeError); + await expect(promise).rejects.toMatchObject({ error: 'invalid_response', status }); + }); + + it.each([ + ['a numeric scope', { scope: 42 }], + ['a null scope', { scope: null }], + ['a string expires_in', { expires_in: '3600' }], + ['a negative expires_in', { expires_in: -1 }], + ['a fractional expires_in', { expires_in: 1.5 }], + ['a null expires_in', { expires_in: null }], + ['a numeric refresh_token', { refresh_token: 7 }], + ['a null refresh_token', { refresh_token: null }], + ])('throws an invalid_response error for a successful response with %s', async (_, member) => { + server.use( + http.post(tokenFetchUrl, () => + HttpResponse.json({ + access_token: 'exchanged_token', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + ...member, + }), + ), + ); + + const promise = createClient().oauthApplications.__experimental_exchangeToken({ + publishableKey, + clientId, + clientSecret, + subjectToken: 'subject_token', + resource: 'https://r.example.test', + }); + + await expect(promise).rejects.toBeInstanceOf(OAuthTokenExchangeError); + await expect(promise).rejects.toMatchObject({ error: 'invalid_response', status: 200 }); + }); + + it('ignores unrecognized members in a successful response', async () => { + server.use( + http.post(tokenFetchUrl, () => + HttpResponse.json({ + access_token: 'exchanged_token', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + expires_in: 0, + refresh_token: 'refresh', + custom_member: { nested: true }, + }), + ), + ); + + const result = await createClient().oauthApplications.__experimental_exchangeToken({ + publishableKey, + clientId, + clientSecret, + subjectToken: 'subject_token', + resource: 'https://r.example.test', + }); + + expect(result).toEqual({ + accessToken: 'exchanged_token', + issuedTokenType: 'urn:ietf:params:oauth:token-type:access_token', + tokenType: 'Bearer', + expiresIn: 0, + scope: undefined, + refreshToken: 'refresh', + }); + }); + it('sends the exchange to the proxy URL when one is configured', async () => { let requested = false; server.use( diff --git a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts index 2a6a3b552d6..d99e692bfb5 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -134,12 +134,27 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } +function isAbsentOr(value: Record, key: string, check: (member: unknown) => boolean) { + return !(key in value) || check(value[key]); +} + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +function isNonNegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + function isTokenExchangeResponse(value: unknown): value is TokenExchangeResponseJSON { return ( isRecord(value) && - typeof value.access_token === 'string' && - typeof value.issued_token_type === 'string' && - typeof value.token_type === 'string' + isString(value.access_token) && + isString(value.issued_token_type) && + isString(value.token_type) && + isAbsentOr(value, 'scope', isString) && + isAbsentOr(value, 'expires_in', isNonNegativeInteger) && + isAbsentOr(value, 'refresh_token', isString) ); } @@ -322,7 +337,7 @@ export class OAuthApplicationsApi extends AbstractAPI { throw tokenExchangeError(json, response.status); } - if (!isTokenExchangeResponse(json)) { + if (response.status !== 200 || !isTokenExchangeResponse(json)) { throw new OAuthTokenExchangeError({ error: 'invalid_response', status: response.status }); } From 39c1920eda2ad6e4d8c914cd2102b717be774762 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Nov 2026 19:20:39 -0600 Subject: [PATCH 11/14] fix(backend): validate token-exchange resource before any request Reject a resource with a fragment, a non-absolute URI, or a scheme other than https or loopback http with an invalid_request OAuthTokenExchangeError before any network request (RFC 8693 section 2.1). The caller's original string is still sent unchanged. --- .../__tests__/OAuthApplicationsApi.test.ts | 63 +++++++++++++++++++ .../src/api/endpoints/OAuthApplicationsApi.ts | 37 +++++++++++ 2 files changed, 100 insertions(+) diff --git a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts index 4183737e28e..f8eb81c9f1c 100644 --- a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts +++ b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts @@ -319,6 +319,69 @@ describe('OAuthApplications', () => { expect(requested).toBe(false); }); + it.each([ + ['has a fragment', 'https://r.example.test/api#section'], + ['has a bare trailing fragment delimiter', 'https://r.example.test/api#'], + ['is relative', '/api'], + ['is unparsable', 'https://'], + ['is empty', ''], + ['uses plain http for a non-loopback host', 'http://r.example.test/api'], + ['uses plain http for a loopback-looking host name', 'http://localhost.example.test'], + ['uses a non-http scheme', 'urn:example:resource'], + ['uses a websocket scheme', 'wss://r.example.test/api'], + ])('rejects a resource that %s before making any request', async (_, resource) => { + const requested: string[] = []; + server.use( + http.all('*', ({ request }) => { + requested.push(`${request.method} ${request.url}`); + return tokenResponse(); + }), + ); + + const promise = createClient().oauthApplications.__experimental_exchangeToken({ + publishableKey, + clientId, + clientSecret, + subjectToken: 'subject_token', + resource, + }); + + await expect(promise).rejects.toBeInstanceOf(OAuthTokenExchangeError); + await expect(promise).rejects.toMatchObject({ + code: 'invalid_request', + error: 'invalid_request', + errorDescription: expect.stringContaining('resource'), + status: undefined, + }); + expect(requested).toEqual([]); + }); + + it.each([ + ['an https resource with a query', 'https://r.example.test/api?tenant=a&b=1'], + ['an https resource as written, without normalization', 'HTTPS://R.Example.test'], + ['an http resource on localhost', 'http://localhost:3000'], + ['an http resource on 127.0.0.1', 'http://127.0.0.1:8080/api'], + ['an http resource on [::1]', 'http://[::1]:8080'], + ])('sends %s', async (_, resource) => { + let sent: string[] | undefined; + server.use( + http.post(tokenFetchUrl, async ({ request }) => { + sent = new URLSearchParams(await request.text()).getAll('resource'); + return tokenResponse(); + }), + ); + + await createClient().oauthApplications.__experimental_exchangeToken({ + publishableKey, + clientId, + clientSecret, + subjectToken: 'subject_token', + resource, + }); + + expect(sent).toEqual([resource]); + }); + it('throws when the client ID is missing', async () => { await expect( createClient().oauthApplications.__experimental_exchangeToken({ diff --git a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts index 2a6a3b552d6..b207eeb7a86 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -117,6 +117,41 @@ function formUrlEncode(value: string) { return new URLSearchParams({ v: value }).toString().slice(2); } +function invalidTokenExchangeRequest(errorDescription: string) { + return new OAuthTokenExchangeError({ error: 'invalid_request', errorDescription }); +} + +const loopbackHostnames = new Set(['localhost', '127.0.0.1', '[::1]']); + +function parseAbsoluteUrl(value: string) { + try { + return new URL(value); + } catch { + return undefined; + } +} + +function isAllowedResourceUrl({ protocol, hostname }: URL) { + return protocol === 'https:' || (protocol === 'http:' && loopbackHostnames.has(hostname)); +} + +function assertValidTokenExchangeResource(resource: string) { + if (resource.includes('#')) { + throw invalidTokenExchangeRequest('`resource` must not contain a fragment.'); + } + const url = parseAbsoluteUrl(resource); + if (!url) { + throw invalidTokenExchangeRequest('`resource` must be an absolute URI.'); + } + if (!isAllowedResourceUrl(url)) { + throw invalidTokenExchangeRequest('`resource` must use https, or http on a loopback host.'); + } +} + +function assertValidTokenExchangeParams(params: ExchangeOAuthApplicationTokenParams) { + assertValidTokenExchangeResource(params.resource); +} + function tokenExchangeBody(params: ExchangeOAuthApplicationTokenParams) { const body = new URLSearchParams({ grant_type: tokenExchangeGrantType, @@ -301,6 +336,8 @@ export class OAuthApplicationsApi extends AbstractAPI { public async __experimental_exchangeToken( params: ExchangeOAuthApplicationTokenParams, ): Promise { + assertValidTokenExchangeParams(params); + if (!params.clientId) { throw new Error('A valid client ID is required.'); } From e74b32d1c658934a5547d12e074f08026a84f092 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Nov 2026 19:25:16 -0600 Subject: [PATCH 12/14] fix(backend): reject invalid token-exchange scope elements --- .../__tests__/OAuthApplicationsApi.test.ts | 60 +++++++++++++++++++ .../src/api/endpoints/OAuthApplicationsApi.ts | 12 ++++ 2 files changed, 72 insertions(+) diff --git a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts index fffd37e38b9..eea6e04db9d 100644 --- a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts +++ b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts @@ -475,6 +475,66 @@ describe('OAuthApplications', () => { expect(sent).toEqual([resource]); }); + it.each([ + ['an empty element', ['read', '']], + ['an element with a space', ['a b']], + ['an element with a double quote', ['a"b']], + ['an element with a backslash', ['a\\b']], + ['an element with a non-ASCII character', ['café']], + ['an element with a control character', ['a\tb']], + ['an element with DEL', ['a\u007fb']], + ])('rejects a scope with %s before making any request', async (_, scope) => { + const requested: string[] = []; + server.use( + http.all('*', ({ request }) => { + requested.push(`${request.method} ${request.url}`); + return tokenResponse(); + }), + ); + + const promise = createClient().oauthApplications.__experimental_exchangeToken({ + publishableKey, + clientId, + clientSecret, + subjectToken: 'subject_token', + resource: 'https://r.example.test', + scope, + }); + + await expect(promise).rejects.toBeInstanceOf(OAuthTokenExchangeError); + await expect(promise).rejects.toMatchObject({ + code: 'invalid_request', + error: 'invalid_request', + errorDescription: expect.stringContaining('scope'), + status: undefined, + }); + expect(requested).toEqual([]); + }); + + it.each([ + ['no scope parameter for an empty scope list', [], null], + ['scope tokens at the edges of the allowed character ranges', ['!', '#', '[', ']', '~'], '! # [ ] ~'], + ])('sends %s', async (_, scope, expected) => { + let body: URLSearchParams | undefined; + server.use( + http.post(tokenFetchUrl, async ({ request }) => { + body = new URLSearchParams(await request.text()); + return tokenResponse(); + }), + ); + + await createClient().oauthApplications.__experimental_exchangeToken({ + publishableKey, + clientId, + clientSecret, + subjectToken: 'subject_token', + resource: 'https://r.example.test', + scope, + }); + + expect(body?.get('scope')).toBe(expected); + }); + it('throws when the client ID is missing', async () => { await expect( createClient().oauthApplications.__experimental_exchangeToken({ diff --git a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts index c9170907657..4f8ad12f95b 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -148,8 +148,20 @@ function assertValidTokenExchangeResource(resource: string) { } } +// RFC 6749 §3.3: scope-token = 1*( %x21 / %x23-5B / %x5D-7E ) +const scopeTokenPattern = /^[\x21\x23-\x5B\x5D-\x7E]+$/; + +function assertValidTokenExchangeScope(scope: string[] | undefined) { + if (scope?.some(token => !scopeTokenPattern.test(token))) { + throw invalidTokenExchangeRequest( + 'Each `scope` element must be a non-empty scope token without spaces, `"`, `\\`, or non-ASCII characters.', + ); + } +} + function assertValidTokenExchangeParams(params: ExchangeOAuthApplicationTokenParams) { assertValidTokenExchangeResource(params.resource); + assertValidTokenExchangeScope(params.scope); } function tokenExchangeBody(params: ExchangeOAuthApplicationTokenParams) { From 0f548367a2b62cb5e6af50179c6cc2e73e048452 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Nov 2026 19:28:57 -0600 Subject: [PATCH 13/14] docs(backend): document token-exchange validation contract --- .changeset/oauth-application-token-exchange.md | 4 +++- .../backend/src/api/endpoints/OAuthApplicationsApi.ts | 11 +++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.changeset/oauth-application-token-exchange.md b/.changeset/oauth-application-token-exchange.md index 7ce9b371ec0..4560319502e 100644 --- a/.changeset/oauth-application-token-exchange.md +++ b/.changeset/oauth-application-token-exchange.md @@ -2,4 +2,6 @@ '@clerk/backend': minor --- -Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass your publishable key, the application's client ID and client secret, a user's OAuth access token, and the target `resource` to receive a new access token for that resource, for example to call a downstream API on a user's behalf. The request goes to your instance's token endpoint, derived from the publishable key (or from `proxyUrl` when set), so it does not require a secret key. If the token endpoint rejects the exchange, the method throws an `OAuthTokenExchangeError`, exported from `@clerk/backend/errors`, with the OAuth error code in `code` (also available as `error`), `errorDescription`, and HTTP `status`. +Add an experimental `clerkClient.oauthApplications.__experimental_exchangeToken()` method for performing an OAuth 2.0 Token Exchange (RFC 8693) on behalf of an OAuth application. Pass your publishable key, the application's client ID and client secret, a user's OAuth access token, and the target `resource` to receive a new access token for that resource, for example to call a downstream API on a user's behalf. The request goes to your instance's token endpoint, derived from the publishable key (or from `proxyUrl` when set), so it does not require a secret key. + +The method checks its inputs before making any request: `resource` must be an absolute URI without a fragment that uses `https` (or `http` on `localhost`, `127.0.0.1` or `[::1]`), and each `scope` element must be a single scope token, without spaces, `"`, `\` or non-ASCII characters. Invalid input throws an `OAuthTokenExchangeError` with code `invalid_request` and no `status`. Only a `200` response with a well-formed token response counts as success; any other success status, or a response with missing or mistyped members, throws an `OAuthTokenExchangeError` with code `invalid_response`. If the token endpoint rejects the exchange, the error carries the OAuth error code in `code` (also available as `error`), `errorDescription`, and HTTP `status`. `OAuthTokenExchangeError` is exported from `@clerk/backend/errors`. Network failures from `fetch` are not wrapped. diff --git a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts index 4f8ad12f95b..4f2d35d421f 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -65,9 +65,9 @@ export type ExchangeOAuthApplicationTokenParams = { clientSecret: string; /** The OAuth access token that represents the user on whose behalf the request is being made. */ subjectToken: string; - /** The URI of the target resource where the issued token will be used. */ + /** The absolute URI of the target resource where the issued token will be used. It must use `https`, or `http` on `localhost`, `127.0.0.1` or `[::1]`, and must not contain a fragment. It is sent exactly as given. */ resource: string; - /** The scopes requested for the issued token. */ + /** The scopes requested for the issued token. Each element must be a single, non-empty [scope token](https://www.rfc-editor.org/rfc/rfc6749#section-3.3): printable ASCII without spaces, `"` or `\`. */ scope?: string[]; }; @@ -79,7 +79,7 @@ export type ExchangeOAuthApplicationTokenResult = { accessToken: string; /** The type of the issued token. */ issuedTokenType: OAuthTokenTypeIdentifier; - /** How the issued token is used, for example `Bearer`. */ + /** How the issued token is used, for example `Bearer`, as returned by the authorization server. Compare it case-insensitively ([RFC 6749 section 5.1](https://www.rfc-editor.org/rfc/rfc6749#section-5.1)), and only use the token if your code supports this type ([RFC 6749 section 7.1](https://www.rfc-editor.org/rfc/rfc6749#section-7.1)). */ tokenType: string; /** The lifetime of the issued token in seconds, if provided by the authorization server. */ expiresIn?: number; @@ -346,8 +346,11 @@ export class OAuthApplicationsApi extends AbstractAPI { * * @param params - The parameters for the token exchange. * @returns The issued token and its metadata. - * @throws `OAuthTokenExchangeError` if the token endpoint rejects the exchange or returns an invalid response. + * @throws `OAuthTokenExchangeError` with code `invalid_request` and no `status` if `resource` or `scope` is invalid. No request is made. + * @throws `OAuthTokenExchangeError` with the OAuth error code, `errorDescription` and HTTP `status` if the token endpoint rejects the exchange ([RFC 6749 section 5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2)). + * @throws `OAuthTokenExchangeError` with code `invalid_response` and the HTTP `status` if the token endpoint returns a non-200 success status, an error without an OAuth error code, or a malformed token response. * @throws `Error` if `clientId` is empty, `publishableKey` is missing or invalid, or `proxyUrl` is not an absolute URL. + * @throws Network failures from `fetch`, unwrapped and unchanged. * * @example * ```ts From d340537adfb3521fa0b15e2da90b637012f04bcd Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 11 Nov 2026 00:09:25 -0600 Subject: [PATCH 14/14] fix(backend): reject non-RFC 3986 token-exchange resources Check the raw resource string against the RFC 3986 character set and pct-encoding before the WHATWG parse, so strings WHATWG repairs (spaces, tabs, backslashes, non-ASCII, bad escapes) are rejected instead of sent. Also add an invalid_response error factory, rename the invalid_request factory, drop a code comment and a cast, and share test helpers. --- .../__tests__/OAuthApplicationsApi.test.ts | 106 +++++++----------- .../src/api/endpoints/OAuthApplicationsApi.ts | 30 +++-- 2 files changed, 59 insertions(+), 77 deletions(-) diff --git a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts index eea6e04db9d..795bcd3c073 100644 --- a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts +++ b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import { OAuthTokenExchangeError } from '../../errors'; import { server, validateHeaders } from '../../mock-server'; +import type { ExchangeOAuthApplicationTokenParams } from '../endpoints'; import { createBackendApiClient } from '../factory'; describe('OAuthApplications', () => { @@ -95,6 +96,27 @@ describe('OAuthApplications', () => { token_type: 'Bearer', }); + const exchange = (overrides: Partial = {}) => + createClient().oauthApplications.__experimental_exchangeToken({ + publishableKey, + clientId, + clientSecret, + subjectToken: 'subject_token', + resource: 'https://r.example.test', + ...overrides, + }); + + const recordRequests = () => { + const requested: string[] = []; + server.use( + http.all('*', ({ request }) => { + requested.push(`${request.method} ${request.url}`); + return tokenResponse(); + }), + ); + return requested; + }; + it('exchanges a subject token at the application token endpoint', async () => { let captured: { headers: Headers; body: URLSearchParams } | undefined; server.use( @@ -289,13 +311,7 @@ describe('OAuthApplications', () => { ), ); - const promise = createClient().oauthApplications.__experimental_exchangeToken({ - publishableKey, - clientId, - clientSecret, - subjectToken: 'subject_token', - resource: 'https://r.example.test', - }); + const promise = exchange(); await expect(promise).rejects.toBeInstanceOf(OAuthTokenExchangeError); await expect(promise).rejects.toMatchObject({ error: 'invalid_response', status }); @@ -322,13 +338,7 @@ describe('OAuthApplications', () => { ), ); - const promise = createClient().oauthApplications.__experimental_exchangeToken({ - publishableKey, - clientId, - clientSecret, - subjectToken: 'subject_token', - resource: 'https://r.example.test', - }); + const promise = exchange(); await expect(promise).rejects.toBeInstanceOf(OAuthTokenExchangeError); await expect(promise).rejects.toMatchObject({ error: 'invalid_response', status: 200 }); @@ -348,13 +358,7 @@ describe('OAuthApplications', () => { ), ); - const result = await createClient().oauthApplications.__experimental_exchangeToken({ - publishableKey, - clientId, - clientSecret, - subjectToken: 'subject_token', - resource: 'https://r.example.test', - }); + const result = await exchange(); expect(result).toEqual({ accessToken: 'exchanged_token', @@ -422,22 +426,20 @@ describe('OAuthApplications', () => { ['uses plain http for a loopback-looking host name', 'http://localhost.example.test'], ['uses a non-http scheme', 'urn:example:resource'], ['uses a websocket scheme', 'wss://r.example.test/api'], + ['has surrounding spaces', ' https://a.example '], + ['has a tab', 'https://a.ex\tample/'], + ['uses backslashes', 'https:\\\\a.example\\p'], + ['has a space', 'https://a.example/a b'], + ['has a non-ASCII character', 'https://a.example/é'], + ['has a newline', 'https://a.example/a\nb'], + ['has an invalid percent-encoding', 'https://a.example/%zz'], + ['has a leading control character', '\u0000https://a.example'], + ['has no authority', 'https:a.example'], + ['hides a host behind a backslash', 'http://localhost\\@evil.example/'], ])('rejects a resource that %s before making any request', async (_, resource) => { - const requested: string[] = []; - server.use( - http.all('*', ({ request }) => { - requested.push(`${request.method} ${request.url}`); - return tokenResponse(); - }), - ); + const requested = recordRequests(); - const promise = createClient().oauthApplications.__experimental_exchangeToken({ - publishableKey, - clientId, - clientSecret, - subjectToken: 'subject_token', - resource, - }); + const promise = exchange({ resource }); await expect(promise).rejects.toBeInstanceOf(OAuthTokenExchangeError); await expect(promise).rejects.toMatchObject({ @@ -464,13 +466,7 @@ describe('OAuthApplications', () => { }), ); - await createClient().oauthApplications.__experimental_exchangeToken({ - publishableKey, - clientId, - clientSecret, - subjectToken: 'subject_token', - resource, - }); + await exchange({ resource }); expect(sent).toEqual([resource]); }); @@ -484,22 +480,9 @@ describe('OAuthApplications', () => { ['an element with a control character', ['a\tb']], ['an element with DEL', ['a\u007fb']], ])('rejects a scope with %s before making any request', async (_, scope) => { - const requested: string[] = []; - server.use( - http.all('*', ({ request }) => { - requested.push(`${request.method} ${request.url}`); - return tokenResponse(); - }), - ); + const requested = recordRequests(); - const promise = createClient().oauthApplications.__experimental_exchangeToken({ - publishableKey, - clientId, - clientSecret, - subjectToken: 'subject_token', - resource: 'https://r.example.test', - scope, - }); + const promise = exchange({ scope }); await expect(promise).rejects.toBeInstanceOf(OAuthTokenExchangeError); await expect(promise).rejects.toMatchObject({ @@ -523,14 +506,7 @@ describe('OAuthApplications', () => { }), ); - await createClient().oauthApplications.__experimental_exchangeToken({ - publishableKey, - clientId, - clientSecret, - subjectToken: 'subject_token', - resource: 'https://r.example.test', - scope, - }); + await exchange({ scope }); expect(body?.get('scope')).toBe(expected); }); diff --git a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts index 4f2d35d421f..38c79fbf505 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -117,10 +117,14 @@ function formUrlEncode(value: string) { return new URLSearchParams({ v: value }).toString().slice(2); } -function invalidTokenExchangeRequest(errorDescription: string) { +function tokenExchangeRequestError(errorDescription: string) { return new OAuthTokenExchangeError({ error: 'invalid_request', errorDescription }); } +function tokenExchangeResponseError(status: number) { + return new OAuthTokenExchangeError({ error: 'invalid_response', status }); +} + const loopbackHostnames = new Set(['localhost', '127.0.0.1', '[::1]']); function parseAbsoluteUrl(value: string) { @@ -135,25 +139,27 @@ function isAllowedResourceUrl({ protocol, hostname }: URL) { return protocol === 'https:' || (protocol === 'http:' && loopbackHostnames.has(hostname)); } +const rfc3986AbsoluteUriPattern = + /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(?:[A-Za-z0-9\-._~:/?@!$&'()*+,;=[\]]|%[0-9A-Fa-f]{2})*$/; + function assertValidTokenExchangeResource(resource: string) { if (resource.includes('#')) { - throw invalidTokenExchangeRequest('`resource` must not contain a fragment.'); + throw tokenExchangeRequestError('`resource` must not contain a fragment.'); } - const url = parseAbsoluteUrl(resource); + const url = rfc3986AbsoluteUriPattern.test(resource) ? parseAbsoluteUrl(resource) : undefined; if (!url) { - throw invalidTokenExchangeRequest('`resource` must be an absolute URI.'); + throw tokenExchangeRequestError('`resource` must be an absolute URI.'); } if (!isAllowedResourceUrl(url)) { - throw invalidTokenExchangeRequest('`resource` must use https, or http on a loopback host.'); + throw tokenExchangeRequestError('`resource` must use https, or http on a loopback host.'); } } -// RFC 6749 §3.3: scope-token = 1*( %x21 / %x23-5B / %x5D-7E ) const scopeTokenPattern = /^[\x21\x23-\x5B\x5D-\x7E]+$/; function assertValidTokenExchangeScope(scope: string[] | undefined) { if (scope?.some(token => !scopeTokenPattern.test(token))) { - throw invalidTokenExchangeRequest( + throw tokenExchangeRequestError( 'Each `scope` element must be a non-empty scope token without spaces, `"`, `\\`, or non-ASCII characters.', ); } @@ -190,7 +196,7 @@ function isString(value: unknown): value is string { } function isNonNegativeInteger(value: unknown): value is number { - return Number.isSafeInteger(value) && (value as number) >= 0; + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; } function isTokenExchangeResponse(value: unknown): value is TokenExchangeResponseJSON { @@ -217,12 +223,12 @@ function tokenExchangeResult(json: TokenExchangeResponseJSON): ExchangeOAuthAppl } function tokenExchangeError(json: unknown, status: number) { - if (!isRecord(json) || typeof json.error !== 'string') { - return new OAuthTokenExchangeError({ error: 'invalid_response', status }); + if (!isRecord(json) || !isString(json.error)) { + return tokenExchangeResponseError(status); } return new OAuthTokenExchangeError({ error: json.error, - errorDescription: typeof json.error_description === 'string' ? json.error_description : undefined, + errorDescription: isString(json.error_description) ? json.error_description : undefined, status, }); } @@ -390,7 +396,7 @@ export class OAuthApplicationsApi extends AbstractAPI { } if (response.status !== 200 || !isTokenExchangeResponse(json)) { - throw new OAuthTokenExchangeError({ error: 'invalid_response', status: response.status }); + throw tokenExchangeResponseError(response.status); } return tokenExchangeResult(json);