diff --git a/.changeset/oauth-application-token-exchange.md b/.changeset/oauth-application-token-exchange.md new file mode 100644 index 00000000000..4560319502e --- /dev/null +++ b/.changeset/oauth-application-token-exchange.md @@ -0,0 +1,7 @@ +--- +'@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. + +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/__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 14afa71e0ec..795bcd3c073 100644 --- a/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts +++ b/packages/backend/src/api/__tests__/OAuthApplicationsApi.test.ts @@ -1,7 +1,11 @@ +import { ClerkError } from '@clerk/shared/error'; +import { buildPublishableKey } from '@clerk/shared/keys'; import { http, HttpResponse } from 'msw'; 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', () => { @@ -75,4 +79,448 @@ 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 publishableKey = buildPublishableKey('clerk.example.test'); + + const createClient = () => createBackendApiClient({ apiUrl: 'https://api.clerk.test' }); + + const tokenResponse = () => + HttpResponse.json({ + access_token: 'exchanged_token', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + 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( + 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({ + publishableKey, + clientId, + clientSecret, + subjectToken: 'subject_token', + 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?.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 () => { + let authorization: string | null = null; + server.use( + http.post(tokenFetchUrl, ({ request }) => { + authorization = request.headers.get('Authorization'); + return tokenResponse(); + }), + ); + + await createClient().oauthApplications.__experimental_exchangeToken({ + publishableKey, + clientId, + clientSecret, + subjectToken: 'subject_token', + resource: 'https://r.example.test', + }); + + expect(authorization).toBe(`Basic ${btoa('client+id%3Awith%2Breserved:secret+value%3A%2B%2F%3D')}`); + }); + + it('omits optional parameters that are not provided', async () => { + 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({ + publishableKey, + clientId, + clientSecret, + subjectToken: 'subject_token', + resource: 'https://r.example.test', + }); + + expect([...(body?.keys() ?? [])]).toEqual(['grant_type', 'subject_token', 'subject_token_type', 'resource']); + expect(result.scope).toBeUndefined(); + expect(result.refreshToken).toBe('refresh'); + }); + + it('ignores empty entries in a returned scope', 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', + scope: ' invoices:read invoices:write ', + }), + ), + ); + + const result = await createClient().oauthApplications.__experimental_exchangeToken({ + publishableKey, + clientId, + clientSecret, + subjectToken: 'subject_token', + resource: 'https://r.example.test', + }); + + expect(result.scope).toEqual(['invoices:read', 'invoices:write']); + }); + + 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) => { + server.use( + http.post(tokenFetchUrl, () => + HttpResponse.json( + { error, ...(errorDescription && { error_description: errorDescription }) }, + { status: 400 }, + ), + ), + ); + + 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.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 () => { + server.use(http.post(tokenFetchUrl, () => new HttpResponse('Bad Gateway', { status: 502 }))); + + await expect( + createClient().oauthApplications.__experimental_exchangeToken({ + publishableKey, + clientId, + clientSecret, + subjectToken: 'subject_token', + resource: 'https://r.example.test', + }), + ).rejects.toMatchObject({ error: 'invalid_response', errorDescription: undefined, status: 502 }); + }); + + it('throws an invalid_response error when a successful response is malformed', async () => { + server.use(http.post(tokenFetchUrl, () => HttpResponse.json({ token_type: 'Bearer' }))); + + await expect( + createClient().oauthApplications.__experimental_exchangeToken({ + publishableKey, + clientId, + clientSecret, + subjectToken: 'subject_token', + resource: 'https://r.example.test', + }), + ).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 = exchange(); + + 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 = exchange(); + + 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 exchange(); + + 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( + 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({ + ...endpointParams, + clientId, + clientSecret, + subjectToken: 'subject_token', + resource: 'https://r.example.test', + }), + ).rejects.toThrow(message); + 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'], + ['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 = recordRequests(); + + const promise = exchange({ 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 exchange({ resource }); + + 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 = recordRequests(); + + const promise = exchange({ 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 exchange({ scope }); + + expect(body?.get('scope')).toBe(expected); + }); + + 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 620e0d57f2b..38c79fbf505 100644 --- a/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts +++ b/packages/backend/src/api/endpoints/OAuthApplicationsApi.ts @@ -1,6 +1,10 @@ -import type { ClerkPaginationRequest } from '@clerk/shared/types'; +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'; @@ -37,6 +41,54 @@ 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 = 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' +>; + +/** + * 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 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. */ + subjectToken: string; + /** 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. 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[]; +}; + +/** + * 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`, 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; + /** 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; +}; + /** @generateWithEmptyComment */ export type GetOAuthApplicationListParams = ClerkPaginationRequest<{ /** @@ -49,6 +101,149 @@ export type GetOAuthApplicationListParams = ClerkPaginationRequest<{ 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 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 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) { + try { + return new URL(value); + } catch { + return undefined; + } +} + +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 tokenExchangeRequestError('`resource` must not contain a fragment.'); + } + const url = rfc3986AbsoluteUriPattern.test(resource) ? parseAbsoluteUrl(resource) : undefined; + if (!url) { + throw tokenExchangeRequestError('`resource` must be an absolute URI.'); + } + if (!isAllowedResourceUrl(url)) { + throw tokenExchangeRequestError('`resource` must use https, or http on a loopback host.'); + } +} + +const scopeTokenPattern = /^[\x21\x23-\x5B\x5D-\x7E]+$/; + +function assertValidTokenExchangeScope(scope: string[] | undefined) { + if (scope?.some(token => !scopeTokenPattern.test(token))) { + throw tokenExchangeRequestError( + '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) { + const body = new URLSearchParams({ + grant_type: tokenExchangeGrantType, + subject_token: params.subjectToken, + subject_token_type: accessTokenType, + resource: params.resource, + }); + if (params.scope?.length) { + body.set('scope', params.scope.join(' ')); + } + return body; +} + +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 typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function isTokenExchangeResponse(value: unknown): value is TokenExchangeResponseJSON { + return ( + isRecord(value) && + 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) + ); +} + +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(' ').filter(Boolean) : undefined, + refreshToken: json.refresh_token, + }; +} + +function tokenExchangeError(json: unknown, status: number) { + if (!isRecord(json) || !isString(json.error)) { + return tokenExchangeResponseError(status); + } + return new OAuthTokenExchangeError({ + error: json.error, + errorDescription: isString(json.error_description) ? json.error_description : undefined, + status, + }); +} + +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 { /** @@ -149,4 +344,61 @@ 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 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` 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 + * const { accessToken } = await clerkClient.oauthApplications.__experimental_exchangeToken({ + * publishableKey: process.env.CLERK_PUBLISHABLE_KEY!, + * clientId: process.env.OAUTH_CLIENT_ID!, + * clientSecret: process.env.OAUTH_CLIENT_SECRET!, + * subjectToken, + * resource: 'https://api.example.com', + * }); + * ``` + */ + public async __experimental_exchangeToken( + params: ExchangeOAuthApplicationTokenParams, + ): Promise { + assertValidTokenExchangeParams(params); + + if (!params.clientId) { + throw new Error('A valid client ID is required.'); + } + + const tokenEndpoint = oauthTokenEndpoint(params); + const credentials = `${formUrlEncode(params.clientId)}:${formUrlEncode(params.clientSecret)}`; + const body = tokenExchangeBody(params); + const headers = { + Accept: 'application/json', + Authorization: `Basic ${btoa(credentials)}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + const response = await runtime.fetch(tokenEndpoint, { method: 'POST', headers, body }); + + const json = await response.json().catch(() => undefined); + + if (!response.ok) { + throw tokenExchangeError(json, response.status); + } + + if (response.status !== 200 || !isTokenExchangeResponse(json)) { + throw tokenExchangeResponseError(response.status); + } + + return tokenExchangeResult(json); + } } diff --git a/packages/backend/src/errors.ts b/packages/backend/src/errors.ts index 3ebc0bf9234..d87a9b8df5b 100644 --- a/packages/backend/src/errors.ts +++ b/packages/backend/src/errors.ts @@ -117,3 +117,24 @@ export class MachineTokenVerificationError extends ClerkError { return `${this.message} (code=${this.code}, status=${this.status || 'n/a'})`; } } + +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({ message: errorDescription ? `${error}: ${errorDescription}` : error, code: error }); + Object.setPrototypeOf(this, OAuthTokenExchangeError.prototype); + 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; + } +}