From 20728fb790f96c19f1d0ac68b6f835d67645adba Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 22 Jul 2026 21:41:24 -0400 Subject: [PATCH] feat(backend): support configurable Frontend API proxy URLs - Add fapiUrl support to backend and Next.js proxy helpers, including CLERK_FAPI_URL fallback, URL validation, and trailing-slash normalization for lower-environment FAPI targets. --- .changeset/bright-pandas-proxy.md | 6 ++ packages/backend/src/__tests__/proxy.test.ts | 99 +++++++++++++++++++ packages/backend/src/proxy.ts | 36 ++++++- .../nextjs/src/server/__tests__/proxy.test.ts | 31 ++++++ packages/nextjs/src/server/proxy.ts | 1 + 5 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 .changeset/bright-pandas-proxy.md create mode 100644 packages/nextjs/src/server/__tests__/proxy.test.ts diff --git a/.changeset/bright-pandas-proxy.md b/.changeset/bright-pandas-proxy.md new file mode 100644 index 00000000000..eed956d52d8 --- /dev/null +++ b/.changeset/bright-pandas-proxy.md @@ -0,0 +1,6 @@ +--- +"@clerk/backend": minor +"@clerk/nextjs": minor +--- + +Add an `fapiUrl` option to Frontend API proxy helpers so requests can target a custom Clerk Frontend API URL. diff --git a/packages/backend/src/__tests__/proxy.test.ts b/packages/backend/src/__tests__/proxy.test.ts index 661be0053e6..fbfe5afd41b 100644 --- a/packages/backend/src/__tests__/proxy.test.ts +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -83,14 +83,21 @@ describe('proxy', () => { describe('clerkFrontendApiProxy', () => { const mockFetch = vi.fn(); const originalFetch = global.fetch; + const originalFapiUrl = process.env.CLERK_FAPI_URL; beforeEach(() => { global.fetch = mockFetch; mockFetch.mockReset(); + delete process.env.CLERK_FAPI_URL; }); afterEach(() => { global.fetch = originalFetch; + if (originalFapiUrl === undefined) { + delete process.env.CLERK_FAPI_URL; + } else { + process.env.CLERK_FAPI_URL = originalFapiUrl; + } }); it('returns error when publishableKey is missing', async () => { @@ -197,6 +204,77 @@ describe('proxy', () => { expect(response.status).toBe(200); }); + it('uses the configured FAPI URL over CLERK_FAPI_URL and the publishable key', async () => { + process.env.CLERK_FAPI_URL = 'https://frontend-api.clerkstage.dev'; + mockFetch.mockResolvedValue(new Response(JSON.stringify({ client: {} }), { status: 200 })); + + const request = new Request('https://example.com/__clerk/v1/client'); + + await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + fapiUrl: 'http://localhost:8001/', + }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('http://localhost:8001/v1/client'); + expect(options.headers.get('Host')).toBe('localhost:8001'); + }); + + it('uses CLERK_FAPI_URL when no FAPI URL is configured', async () => { + process.env.CLERK_FAPI_URL = 'https://frontend-api.clerkstage.dev'; + mockFetch.mockResolvedValue(new Response(JSON.stringify({ client: {} }), { status: 200 })); + + const request = new Request('https://example.com/__clerk/v1/client'); + + await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + expect(mockFetch.mock.calls[0][0]).toBe('https://frontend-api.clerkstage.dev/v1/client'); + }); + + it.each([ + ['unsupported scheme', 'ftp://frontend-api.clerk.dev', undefined, 'FAPI URL must use http or https'], + [ + 'credentials', + 'https://user:password@frontend-api.clerk.dev', + undefined, + 'FAPI URL must not include credentials, a query string, or a hash', + ], + [ + 'query string', + 'https://frontend-api.clerk.dev?env=staging', + undefined, + 'FAPI URL must not include credentials, a query string, or a hash', + ], + [ + 'fragment', + 'https://frontend-api.clerk.dev#staging', + undefined, + 'FAPI URL must not include credentials, a query string, or a hash', + ], + ['invalid environment value', undefined, 'not-a-url', 'Invalid URL'], + ])('returns a configuration error for an invalid FAPI URL with %s', async (_case, fapiUrl, envFapiUrl, message) => { + if (envFapiUrl) { + process.env.CLERK_FAPI_URL = envFapiUrl; + } + const request = new Request('https://example.com/__clerk/v1/client'); + + const response = await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + ...(fapiUrl ? { fapiUrl } : {}), + }); + + expect(response.status).toBe(500); + expect(mockFetch).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toMatchObject({ + errors: [{ code: 'proxy_configuration_error', message }], + }); + }); + it('forwards POST request with body', async () => { const mockResponse = new Response(JSON.stringify({ success: true }), { status: 200, @@ -477,6 +555,27 @@ describe('proxy', () => { expect(response.headers.get('Location')).toBe('https://example.com/__clerk/v1/oauth/callback?code=123'); }); + it('rewrites redirects from a configured FAPI URL', async () => { + const mockResponse = new Response(null, { + status: 302, + headers: { + Location: 'http://localhost:8001/v1/oauth/callback?code=123', + }, + }); + mockFetch.mockResolvedValue(mockResponse); + + const request = new Request('https://example.com/__clerk/v1/oauth/authorize'); + + const response = await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + fapiUrl: 'http://localhost:8001', + }); + + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('https://example.com/__clerk/v1/oauth/callback?code=123'); + }); + it('does not rewrite Location header for external redirects', async () => { const mockResponse = new Response(null, { status: 302, diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts index bf2e25789b2..29f751890ba 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -27,6 +27,11 @@ export interface FrontendApiProxyOptions { * The Clerk secret key. Falls back to CLERK_SECRET_KEY env var. */ secretKey?: string; + /** + * The Clerk Frontend API URL. Falls back to the CLERK_FAPI_URL environment variable. + * If not provided, the URL is derived from the publishable key. + */ + fapiUrl?: string; } /** @@ -111,6 +116,20 @@ export function stripTrailingSlashes(str: string): string { return str; } +function normalizeFapiUrl(fapiUrl: string): string { + const url = new URL(fapiUrl); + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('FAPI URL must use http or https'); + } + + if (url.username || url.password || url.search || url.hash) { + throw new Error('FAPI URL must not include credentials, a query string, or a hash'); + } + + return stripTrailingSlashes(url.toString()); +} + /** * Checks if a request path matches the proxy path. * @param request - The incoming request @@ -233,10 +252,23 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend ); } - // Derive the FAPI URL and construct the target URL. + // Resolve the FAPI URL and construct the target URL. // Use string concatenation instead of `new URL(path, base)` to avoid // protocol-relative resolution (e.g., "//evil.com" resolving to a different host). - const fapiBaseUrl = fapiUrlFromPublishableKey(publishableKey); + let fapiBaseUrl: string; + try { + fapiBaseUrl = normalizeFapiUrl( + options?.fapiUrl || + (typeof process !== 'undefined' ? process.env?.CLERK_FAPI_URL : undefined) || + fapiUrlFromPublishableKey(publishableKey), + ); + } catch (error) { + return createErrorResponse( + 'proxy_configuration_error', + error instanceof Error ? error.message : 'Invalid FAPI URL', + 500, + ); + } const fapiHost = new URL(fapiBaseUrl).host; const targetPath = requestUrl.pathname.slice(proxyPath.length) || '/'; const targetUrl = new URL(`${fapiBaseUrl}${targetPath}`); diff --git a/packages/nextjs/src/server/__tests__/proxy.test.ts b/packages/nextjs/src/server/__tests__/proxy.test.ts new file mode 100644 index 00000000000..a866024e472 --- /dev/null +++ b/packages/nextjs/src/server/__tests__/proxy.test.ts @@ -0,0 +1,31 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createFrontendApiProxyHandlers } from '../proxy'; + +describe('createFrontendApiProxyHandlers', () => { + const mockFetch = vi.fn(); + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = mockFetch; + mockFetch.mockReset(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('forwards fapiUrl to the backend proxy', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ client: {} }), { status: 200 })); + const request = new Request('https://example.com/__clerk/v1/client'); + const handlers = createFrontendApiProxyHandlers({ + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + fapiUrl: 'http://localhost:8001', + }); + + await handlers.GET(request); + + expect(mockFetch.mock.calls[0][0]).toBe('http://localhost:8001/v1/client'); + }); +}); diff --git a/packages/nextjs/src/server/proxy.ts b/packages/nextjs/src/server/proxy.ts index fce7ad536fc..026c2b1572c 100644 --- a/packages/nextjs/src/server/proxy.ts +++ b/packages/nextjs/src/server/proxy.ts @@ -52,6 +52,7 @@ export async function clerkFrontendApiProxy( proxyPath: options?.proxyPath || DEFAULT_PROXY_PATH, publishableKey: options?.publishableKey || PUBLISHABLE_KEY, secretKey: options?.secretKey || SECRET_KEY, + fapiUrl: options?.fapiUrl, }); }