Skip to content

Commit 20728fb

Browse files
committed
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.
1 parent 097432d commit 20728fb

5 files changed

Lines changed: 171 additions & 2 deletions

File tree

.changeset/bright-pandas-proxy.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clerk/backend": minor
3+
"@clerk/nextjs": minor
4+
---
5+
6+
Add an `fapiUrl` option to Frontend API proxy helpers so requests can target a custom Clerk Frontend API URL.

packages/backend/src/__tests__/proxy.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,21 @@ describe('proxy', () => {
8383
describe('clerkFrontendApiProxy', () => {
8484
const mockFetch = vi.fn();
8585
const originalFetch = global.fetch;
86+
const originalFapiUrl = process.env.CLERK_FAPI_URL;
8687

8788
beforeEach(() => {
8889
global.fetch = mockFetch;
8990
mockFetch.mockReset();
91+
delete process.env.CLERK_FAPI_URL;
9092
});
9193

9294
afterEach(() => {
9395
global.fetch = originalFetch;
96+
if (originalFapiUrl === undefined) {
97+
delete process.env.CLERK_FAPI_URL;
98+
} else {
99+
process.env.CLERK_FAPI_URL = originalFapiUrl;
100+
}
94101
});
95102

96103
it('returns error when publishableKey is missing', async () => {
@@ -197,6 +204,77 @@ describe('proxy', () => {
197204
expect(response.status).toBe(200);
198205
});
199206

207+
it('uses the configured FAPI URL over CLERK_FAPI_URL and the publishable key', async () => {
208+
process.env.CLERK_FAPI_URL = 'https://frontend-api.clerkstage.dev';
209+
mockFetch.mockResolvedValue(new Response(JSON.stringify({ client: {} }), { status: 200 }));
210+
211+
const request = new Request('https://example.com/__clerk/v1/client');
212+
213+
await clerkFrontendApiProxy(request, {
214+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
215+
secretKey: 'sk_test_xxx',
216+
fapiUrl: 'http://localhost:8001/',
217+
});
218+
219+
const [url, options] = mockFetch.mock.calls[0];
220+
expect(url).toBe('http://localhost:8001/v1/client');
221+
expect(options.headers.get('Host')).toBe('localhost:8001');
222+
});
223+
224+
it('uses CLERK_FAPI_URL when no FAPI URL is configured', async () => {
225+
process.env.CLERK_FAPI_URL = 'https://frontend-api.clerkstage.dev';
226+
mockFetch.mockResolvedValue(new Response(JSON.stringify({ client: {} }), { status: 200 }));
227+
228+
const request = new Request('https://example.com/__clerk/v1/client');
229+
230+
await clerkFrontendApiProxy(request, {
231+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
232+
secretKey: 'sk_test_xxx',
233+
});
234+
235+
expect(mockFetch.mock.calls[0][0]).toBe('https://frontend-api.clerkstage.dev/v1/client');
236+
});
237+
238+
it.each([
239+
['unsupported scheme', 'ftp://frontend-api.clerk.dev', undefined, 'FAPI URL must use http or https'],
240+
[
241+
'credentials',
242+
'https://user:password@frontend-api.clerk.dev',
243+
undefined,
244+
'FAPI URL must not include credentials, a query string, or a hash',
245+
],
246+
[
247+
'query string',
248+
'https://frontend-api.clerk.dev?env=staging',
249+
undefined,
250+
'FAPI URL must not include credentials, a query string, or a hash',
251+
],
252+
[
253+
'fragment',
254+
'https://frontend-api.clerk.dev#staging',
255+
undefined,
256+
'FAPI URL must not include credentials, a query string, or a hash',
257+
],
258+
['invalid environment value', undefined, 'not-a-url', 'Invalid URL'],
259+
])('returns a configuration error for an invalid FAPI URL with %s', async (_case, fapiUrl, envFapiUrl, message) => {
260+
if (envFapiUrl) {
261+
process.env.CLERK_FAPI_URL = envFapiUrl;
262+
}
263+
const request = new Request('https://example.com/__clerk/v1/client');
264+
265+
const response = await clerkFrontendApiProxy(request, {
266+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
267+
secretKey: 'sk_test_xxx',
268+
...(fapiUrl ? { fapiUrl } : {}),
269+
});
270+
271+
expect(response.status).toBe(500);
272+
expect(mockFetch).not.toHaveBeenCalled();
273+
await expect(response.json()).resolves.toMatchObject({
274+
errors: [{ code: 'proxy_configuration_error', message }],
275+
});
276+
});
277+
200278
it('forwards POST request with body', async () => {
201279
const mockResponse = new Response(JSON.stringify({ success: true }), {
202280
status: 200,
@@ -477,6 +555,27 @@ describe('proxy', () => {
477555
expect(response.headers.get('Location')).toBe('https://example.com/__clerk/v1/oauth/callback?code=123');
478556
});
479557

558+
it('rewrites redirects from a configured FAPI URL', async () => {
559+
const mockResponse = new Response(null, {
560+
status: 302,
561+
headers: {
562+
Location: 'http://localhost:8001/v1/oauth/callback?code=123',
563+
},
564+
});
565+
mockFetch.mockResolvedValue(mockResponse);
566+
567+
const request = new Request('https://example.com/__clerk/v1/oauth/authorize');
568+
569+
const response = await clerkFrontendApiProxy(request, {
570+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
571+
secretKey: 'sk_test_xxx',
572+
fapiUrl: 'http://localhost:8001',
573+
});
574+
575+
expect(response.status).toBe(302);
576+
expect(response.headers.get('Location')).toBe('https://example.com/__clerk/v1/oauth/callback?code=123');
577+
});
578+
480579
it('does not rewrite Location header for external redirects', async () => {
481580
const mockResponse = new Response(null, {
482581
status: 302,

packages/backend/src/proxy.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ export interface FrontendApiProxyOptions {
2727
* The Clerk secret key. Falls back to CLERK_SECRET_KEY env var.
2828
*/
2929
secretKey?: string;
30+
/**
31+
* The Clerk Frontend API URL. Falls back to the CLERK_FAPI_URL environment variable.
32+
* If not provided, the URL is derived from the publishable key.
33+
*/
34+
fapiUrl?: string;
3035
}
3136

3237
/**
@@ -111,6 +116,20 @@ export function stripTrailingSlashes(str: string): string {
111116
return str;
112117
}
113118

119+
function normalizeFapiUrl(fapiUrl: string): string {
120+
const url = new URL(fapiUrl);
121+
122+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
123+
throw new Error('FAPI URL must use http or https');
124+
}
125+
126+
if (url.username || url.password || url.search || url.hash) {
127+
throw new Error('FAPI URL must not include credentials, a query string, or a hash');
128+
}
129+
130+
return stripTrailingSlashes(url.toString());
131+
}
132+
114133
/**
115134
* Checks if a request path matches the proxy path.
116135
* @param request - The incoming request
@@ -233,10 +252,23 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
233252
);
234253
}
235254

236-
// Derive the FAPI URL and construct the target URL.
255+
// Resolve the FAPI URL and construct the target URL.
237256
// Use string concatenation instead of `new URL(path, base)` to avoid
238257
// protocol-relative resolution (e.g., "//evil.com" resolving to a different host).
239-
const fapiBaseUrl = fapiUrlFromPublishableKey(publishableKey);
258+
let fapiBaseUrl: string;
259+
try {
260+
fapiBaseUrl = normalizeFapiUrl(
261+
options?.fapiUrl ||
262+
(typeof process !== 'undefined' ? process.env?.CLERK_FAPI_URL : undefined) ||
263+
fapiUrlFromPublishableKey(publishableKey),
264+
);
265+
} catch (error) {
266+
return createErrorResponse(
267+
'proxy_configuration_error',
268+
error instanceof Error ? error.message : 'Invalid FAPI URL',
269+
500,
270+
);
271+
}
240272
const fapiHost = new URL(fapiBaseUrl).host;
241273
const targetPath = requestUrl.pathname.slice(proxyPath.length) || '/';
242274
const targetUrl = new URL(`${fapiBaseUrl}${targetPath}`);
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { createFrontendApiProxyHandlers } from '../proxy';
4+
5+
describe('createFrontendApiProxyHandlers', () => {
6+
const mockFetch = vi.fn();
7+
const originalFetch = global.fetch;
8+
9+
beforeEach(() => {
10+
global.fetch = mockFetch;
11+
mockFetch.mockReset();
12+
});
13+
14+
afterEach(() => {
15+
global.fetch = originalFetch;
16+
});
17+
18+
it('forwards fapiUrl to the backend proxy', async () => {
19+
mockFetch.mockResolvedValue(new Response(JSON.stringify({ client: {} }), { status: 200 }));
20+
const request = new Request('https://example.com/__clerk/v1/client');
21+
const handlers = createFrontendApiProxyHandlers({
22+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
23+
secretKey: 'sk_test_xxx',
24+
fapiUrl: 'http://localhost:8001',
25+
});
26+
27+
await handlers.GET(request);
28+
29+
expect(mockFetch.mock.calls[0][0]).toBe('http://localhost:8001/v1/client');
30+
});
31+
});

packages/nextjs/src/server/proxy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export async function clerkFrontendApiProxy(
5252
proxyPath: options?.proxyPath || DEFAULT_PROXY_PATH,
5353
publishableKey: options?.publishableKey || PUBLISHABLE_KEY,
5454
secretKey: options?.secretKey || SECRET_KEY,
55+
fapiUrl: options?.fapiUrl,
5556
});
5657
}
5758

0 commit comments

Comments
 (0)