diff --git a/packages/backend/package.json b/packages/backend/package.json index 6ba972f19f0..4907dfeb625 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -75,6 +75,16 @@ "default": "./dist/webhooks.js" } }, + "./proxy": { + "import": { + "types": "./dist/proxy.d.ts", + "default": "./dist/proxy.mjs" + }, + "require": { + "types": "./dist/proxy.d.ts", + "default": "./dist/proxy.js" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.js", @@ -83,7 +93,8 @@ "errors", "internal", "jwt", - "webhooks" + "webhooks", + "proxy" ], "scripts": { "build": "tsup", diff --git a/packages/backend/proxy/package.json b/packages/backend/proxy/package.json new file mode 100644 index 00000000000..c1c7eaed2c2 --- /dev/null +++ b/packages/backend/proxy/package.json @@ -0,0 +1,5 @@ +{ + "main": "../dist/proxy.js", + "module": "../dist/proxy.mjs", + "types": "../dist/proxy.d.ts" +} diff --git a/packages/backend/src/__tests__/proxy.test.ts b/packages/backend/src/__tests__/proxy.test.ts new file mode 100644 index 00000000000..ef76a722161 --- /dev/null +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -0,0 +1,444 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clerkFrontendApiProxy, fapiUrlFromPublishableKey, matchProxyPath } from '../proxy'; + +describe('proxy', () => { + describe('fapiUrlFromPublishableKey', () => { + it('returns production FAPI URL for production publishable keys', () => { + const pk = 'pk_live_Y2xlcmsuZXhhbXBsZS5jb20k'; // clerk.example.com + const result = fapiUrlFromPublishableKey(pk); + expect(result).toBe('https://frontend-api.clerk.dev'); + }); + + it('returns local FAPI URL for local environment keys', () => { + // Non-legacy local keys (not starting with 'clerk.') should use local FAPI + const pk = 'pk_test_bXlhcHAubGNsY2xlcmsuY29tJA=='; // myapp.lclclerk.com + const result = fapiUrlFromPublishableKey(pk); + expect(result).toBe('https://frontend-api.lclclerk.com'); + }); + + it('returns staging FAPI URL for staging environment keys', () => { + const pk = 'pk_test_Y2xlcmsuYWNjb3VudHNzdGFnZS5kZXYk'; // clerk.accountsstage.dev + const result = fapiUrlFromPublishableKey(pk); + expect(result).toBe('https://frontend-api.clerkstage.dev'); + }); + + it('returns production FAPI URL for legacy dev instance keys', () => { + // Legacy dev instances should use production FAPI + const pk = 'pk_test_Y2xlcmsuZXhhbXBsZS5sY2xjbGVyay5jb20k'; // clerk.example.lclclerk.com + const result = fapiUrlFromPublishableKey(pk); + expect(result).toBe('https://frontend-api.clerk.dev'); + }); + + it('returns production FAPI URL for invalid publishable keys', () => { + const result = fapiUrlFromPublishableKey('invalid_key'); + expect(result).toBe('https://frontend-api.clerk.dev'); + }); + }); + + describe('matchProxyPath', () => { + it('matches request with default proxy path', () => { + const request = new Request('https://example.com/__clerk/v1/client'); + expect(matchProxyPath(request)).toBe(true); + }); + + it('does not match request without proxy path', () => { + const request = new Request('https://example.com/api/users'); + expect(matchProxyPath(request)).toBe(false); + }); + + it('matches request with custom proxy path', () => { + const request = new Request('https://example.com/custom-proxy/v1/client'); + expect(matchProxyPath(request, { proxyPath: '/custom-proxy' })).toBe(true); + }); + + it('does not match request with different custom proxy path', () => { + const request = new Request('https://example.com/__clerk/v1/client'); + expect(matchProxyPath(request, { proxyPath: '/custom-proxy' })).toBe(false); + }); + + it('matches root proxy path request', () => { + const request = new Request('https://example.com/__clerk'); + expect(matchProxyPath(request)).toBe(true); + }); + + it('matches proxy path with trailing slash', () => { + const request = new Request('https://example.com/__clerk/'); + expect(matchProxyPath(request)).toBe(true); + }); + + it('does not match paths that start with proxy path but have no boundary', () => { + // /__clerk-admin should NOT match /__clerk proxy path + const request = new Request('https://example.com/__clerk-admin/v1/client'); + expect(matchProxyPath(request)).toBe(false); + }); + + it('normalizes proxy path with trailing slash', () => { + const request = new Request('https://example.com/__clerk/v1/client'); + // Should match even when proxyPath has trailing slash + expect(matchProxyPath(request, { proxyPath: '/__clerk/' })).toBe(true); + }); + }); + + describe('clerkFrontendApiProxy', () => { + const mockFetch = vi.fn(); + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = mockFetch; + mockFetch.mockReset(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('returns error when publishableKey is missing', async () => { + const request = new Request('https://example.com/__clerk/v1/client'); + + const response = await clerkFrontendApiProxy(request, { + secretKey: 'sk_test_xxx', + }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.errors[0].code).toBe('proxy_configuration_error'); + expect(body.errors[0].message).toContain('publishableKey'); + }); + + it('returns error when secretKey is missing', async () => { + const request = new Request('https://example.com/__clerk/v1/client'); + + const response = await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.errors[0].code).toBe('proxy_configuration_error'); + expect(body.errors[0].message).toContain('secretKey'); + }); + + it('returns error when request path does not match proxy path', async () => { + const request = new Request('https://example.com/api/users'); + + const response = await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + proxyPath: '/__clerk', + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.errors[0].code).toBe('proxy_path_mismatch'); + }); + + it('returns error when path starts with proxy path but has no boundary', async () => { + // /__clerk-admin should NOT match /__clerk proxy path + const request = new Request('https://example.com/__clerk-admin/v1/client'); + + const response = await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + proxyPath: '/__clerk', + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.errors[0].code).toBe('proxy_path_mismatch'); + }); + + it('forwards GET request to FAPI with correct headers', async () => { + const mockResponse = new Response(JSON.stringify({ client: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + mockFetch.mockResolvedValue(mockResponse); + + const request = new Request('https://example.com/__clerk/v1/client', { + method: 'GET', + headers: { + 'User-Agent': 'Test Agent', + }, + }); + + const response = await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + + // Check URL is correctly constructed + expect(url).toBe('https://frontend-api.clerk.dev/v1/client'); + + // Check required headers are set + expect(options.headers.get('Clerk-Proxy-Url')).toBe('https://example.com/__clerk'); + expect(options.headers.get('Clerk-Secret-Key')).toBe('sk_test_xxx'); + expect(options.headers.get('Host')).toBe('frontend-api.clerk.dev'); + + expect(response.status).toBe(200); + }); + + it('forwards POST request with body', async () => { + const mockResponse = new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + mockFetch.mockResolvedValue(mockResponse); + + const requestBody = JSON.stringify({ email: 'test@example.com' }); + const request = new Request('https://example.com/__clerk/v1/sign_ups', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: requestBody, + }); + + const response = await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + + expect(url).toBe('https://frontend-api.clerk.dev/v1/sign_ups'); + expect(options.method).toBe('POST'); + expect(options.duplex).toBe('half'); + + expect(response.status).toBe(200); + }); + + it('preserves query parameters', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }); + mockFetch.mockResolvedValue(mockResponse); + + const request = new Request('https://example.com/__clerk/v1/client?_clerk_js_version=5.0.0'); + + await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('_clerk_js_version=5.0.0'); + }); + + it('forwards X-Forwarded-For header from original request', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }); + mockFetch.mockResolvedValue(mockResponse); + + const request = new Request('https://example.com/__clerk/v1/client', { + headers: { + 'X-Forwarded-For': '192.168.1.1', + }, + }); + + await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.headers.get('X-Forwarded-For')).toBe('192.168.1.1'); + }); + + it('uses CF-Connecting-IP when available', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }); + mockFetch.mockResolvedValue(mockResponse); + + const request = new Request('https://example.com/__clerk/v1/client', { + headers: { + 'CF-Connecting-IP': '10.0.0.1', + 'X-Forwarded-For': '192.168.1.1', + }, + }); + + await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.headers.get('X-Forwarded-For')).toBe('10.0.0.1'); + }); + + it('removes hop-by-hop headers from request', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }); + mockFetch.mockResolvedValue(mockResponse); + + const request = new Request('https://example.com/__clerk/v1/client', { + headers: { + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + 'User-Agent': 'Test', + }, + }); + + await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.headers.has('Connection')).toBe(false); + expect(options.headers.has('Keep-Alive')).toBe(false); + expect(options.headers.has('Transfer-Encoding')).toBe(false); + expect(options.headers.get('User-Agent')).toBe('Test'); + }); + + it('returns 502 when fetch fails', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const request = new Request('https://example.com/__clerk/v1/client'); + + const response = await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + expect(response.status).toBe(502); + const body = await response.json(); + expect(body.errors[0].code).toBe('proxy_request_failed'); + expect(body.errors[0].message).toContain('Network error'); + }); + + it('passes through FAPI response status codes', async () => { + const mockResponse = new Response(JSON.stringify({ errors: [] }), { + status: 401, + statusText: 'Unauthorized', + }); + mockFetch.mockResolvedValue(mockResponse); + + const request = new Request('https://example.com/__clerk/v1/client'); + + const response = await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + expect(response.status).toBe(401); + }); + + it('uses custom proxy path', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }); + mockFetch.mockResolvedValue(mockResponse); + + const request = new Request('https://example.com/custom-clerk/v1/client'); + + await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + proxyPath: '/custom-clerk', + }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://frontend-api.clerk.dev/v1/client'); + expect(options.headers.get('Clerk-Proxy-Url')).toBe('https://example.com/custom-clerk'); + }); + + it('sets X-Forwarded-Host and X-Forwarded-Proto headers', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }); + mockFetch.mockResolvedValue(mockResponse); + + const request = new Request('https://example.com/__clerk/v1/client'); + + await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.headers.get('X-Forwarded-Host')).toBe('example.com'); + expect(options.headers.get('X-Forwarded-Proto')).toBe('https'); + }); + + it('preserves X-Forwarded-Host and X-Forwarded-Proto from upstream proxies', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }); + mockFetch.mockResolvedValue(mockResponse); + + // Simulate request that already passed through an upstream proxy (e.g., CDN) + const request = new Request('https://internal-server.local/__clerk/v1/client', { + headers: { + 'X-Forwarded-Host': 'myapp.example.com', + 'X-Forwarded-Proto': 'https', + }, + }); + + await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + const [, options] = mockFetch.mock.calls[0]; + // Should preserve the original values from upstream proxy, not overwrite with internal-server.local + expect(options.headers.get('X-Forwarded-Host')).toBe('myapp.example.com'); + expect(options.headers.get('X-Forwarded-Proto')).toBe('https'); + }); + + it('rewrites Location header for redirects pointing to FAPI', async () => { + const mockResponse = new Response(null, { + status: 302, + headers: { + Location: 'https://frontend-api.clerk.dev/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', + }); + + 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, + headers: { + Location: 'https://accounts.google.com/oauth/authorize', + }, + }); + 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', + }); + + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('https://accounts.google.com/oauth/authorize'); + }); + + it('preserves relative Location headers', async () => { + const mockResponse = new Response(null, { + status: 302, + headers: { + Location: '/v1/client', + }, + }); + 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', + }); + + expect(response.status).toBe(302); + // Relative URL resolves against FAPI, and since the host matches, it gets rewritten + expect(response.headers.get('Location')).toBe('https://example.com/__clerk/v1/client'); + }); + }); +}); diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts new file mode 100644 index 00000000000..8408b96d79f --- /dev/null +++ b/packages/backend/src/proxy.ts @@ -0,0 +1,277 @@ +import { + DEFAULT_PROXY_PATH, + LEGACY_DEV_INSTANCE_SUFFIXES, + LOCAL_ENV_SUFFIXES, + LOCAL_FAPI_URL, + PROD_FAPI_URL, + STAGING_ENV_SUFFIXES, + STAGING_FAPI_URL, +} from '@clerk/shared/constants'; +import { parsePublishableKey } from '@clerk/shared/keys'; + +export { DEFAULT_PROXY_PATH } from '@clerk/shared/constants'; + +/** + * Options for the Frontend API proxy + */ +export interface FrontendApiProxyOptions { + /** + * The path prefix for proxy requests. Defaults to `/__clerk`. + */ + proxyPath?: string; + /** + * The Clerk publishable key. Falls back to CLERK_PUBLISHABLE_KEY env var. + */ + publishableKey?: string; + /** + * The Clerk secret key. Falls back to CLERK_SECRET_KEY env var. + */ + secretKey?: string; +} + +/** + * Error codes for proxy errors + */ +export type ProxyErrorCode = 'proxy_configuration_error' | 'proxy_path_mismatch' | 'proxy_request_failed'; + +/** + * Error response structure for proxy errors + */ +export interface ProxyError { + code: ProxyErrorCode; + message: string; +} + +// Hop-by-hop headers that should not be forwarded +const HOP_BY_HOP_HEADERS = [ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]; + +/** + * Derives the Frontend API URL from a publishable key. + * @param publishableKey - The Clerk publishable key + * @returns The Frontend API URL for the environment + */ +export function fapiUrlFromPublishableKey(publishableKey: string): string { + const frontendApi = parsePublishableKey(publishableKey)?.frontendApi; + + if (frontendApi?.startsWith('clerk.') && LEGACY_DEV_INSTANCE_SUFFIXES.some(suffix => frontendApi?.endsWith(suffix))) { + return PROD_FAPI_URL; + } + + if (LOCAL_ENV_SUFFIXES.some(suffix => frontendApi?.endsWith(suffix))) { + return LOCAL_FAPI_URL; + } + if (STAGING_ENV_SUFFIXES.some(suffix => frontendApi?.endsWith(suffix))) { + return STAGING_FAPI_URL; + } + return PROD_FAPI_URL; +} + +/** + * Checks if a request path matches the proxy path. + * @param request - The incoming request + * @param options - Proxy options including the proxy path + * @returns True if the request matches the proxy path + */ +export function matchProxyPath(request: Request, options?: Pick): boolean { + const proxyPath = (options?.proxyPath || DEFAULT_PROXY_PATH).replace(/\/+$/, ''); + const url = new URL(request.url); + return url.pathname === proxyPath || url.pathname.startsWith(proxyPath + '/'); +} + +/** + * Creates a JSON error response + */ +function createErrorResponse(code: ProxyErrorCode, message: string, status: number): Response { + const error: ProxyError = { code, message }; + return new Response(JSON.stringify({ errors: [error] }), { + status, + headers: { + 'Content-Type': 'application/json', + }, + }); +} + +/** + * Gets the client IP address from various headers + */ +function getClientIp(request: Request): string | undefined { + const cfConnectingIp = request.headers.get('cf-connecting-ip'); + if (cfConnectingIp) { + return cfConnectingIp; + } + + const xRealIp = request.headers.get('x-real-ip'); + if (xRealIp) { + return xRealIp; + } + + const xForwardedFor = request.headers.get('x-forwarded-for'); + if (xForwardedFor) { + // Take the first IP in the chain + return xForwardedFor.split(',')[0]?.trim(); + } + + return undefined; +} + +/** + * Proxies a request to Clerk's Frontend API. + * + * This function handles forwarding requests from your application to Clerk's + * Frontend API, enabling scenarios where direct communication with Clerk's API + * is blocked or needs to go through your application server. + * + * @param request - The incoming request to proxy + * @param options - Proxy configuration options + * @returns A Response from Clerk's Frontend API + * + * @example + * ```typescript + * import { clerkFrontendApiProxy } from '@clerk/backend/proxy'; + * + * // In a route handler + * const response = await clerkFrontendApiProxy(request, { + * proxyPath: '/__clerk', + * publishableKey: process.env.CLERK_PUBLISHABLE_KEY, + * secretKey: process.env.CLERK_SECRET_KEY, + * }); + * ``` + */ +export async function clerkFrontendApiProxy(request: Request, options?: FrontendApiProxyOptions): Promise { + const proxyPath = (options?.proxyPath || DEFAULT_PROXY_PATH).replace(/\/+$/, ''); + const publishableKey = + options?.publishableKey || (typeof process !== 'undefined' ? process.env?.CLERK_PUBLISHABLE_KEY : undefined); + const secretKey = options?.secretKey || (typeof process !== 'undefined' ? process.env?.CLERK_SECRET_KEY : undefined); + + // Validate configuration + if (!publishableKey) { + return createErrorResponse( + 'proxy_configuration_error', + 'Missing publishableKey. Provide it in options or set CLERK_PUBLISHABLE_KEY environment variable.', + 500, + ); + } + + if (!secretKey) { + return createErrorResponse( + 'proxy_configuration_error', + 'Missing secretKey. Provide it in options or set CLERK_SECRET_KEY environment variable.', + 500, + ); + } + + // Get the request URL and validate path + const requestUrl = new URL(request.url); + const pathMatches = requestUrl.pathname === proxyPath || requestUrl.pathname.startsWith(proxyPath + '/'); + if (!pathMatches) { + return createErrorResponse( + 'proxy_path_mismatch', + `Request path "${requestUrl.pathname}" does not match proxy path "${proxyPath}"`, + 400, + ); + } + + // Derive the FAPI URL and construct the target URL + const fapiBaseUrl = fapiUrlFromPublishableKey(publishableKey); + const targetPath = requestUrl.pathname.slice(proxyPath.length) || '/'; + const targetUrl = new URL(targetPath, fapiBaseUrl); + targetUrl.search = requestUrl.search; + + // Build headers for the proxied request + const headers = new Headers(); + + // Copy original headers, excluding hop-by-hop headers + request.headers.forEach((value, key) => { + if (!HOP_BY_HOP_HEADERS.includes(key.toLowerCase())) { + headers.set(key, value); + } + }); + + // Set required Clerk proxy headers + const proxyUrl = `${requestUrl.protocol}//${requestUrl.host}${proxyPath}`; + headers.set('Clerk-Proxy-Url', proxyUrl); + headers.set('Clerk-Secret-Key', secretKey); + + // Set the host header to the FAPI host + const fapiHost = new URL(fapiBaseUrl).host; + headers.set('Host', fapiHost); + + // Set X-Forwarded-* headers for proxy awareness + // Only set these if not already present (preserve values from upstream proxies) + if (!headers.has('X-Forwarded-Host')) { + headers.set('X-Forwarded-Host', requestUrl.host); + } + if (!headers.has('X-Forwarded-Proto')) { + headers.set('X-Forwarded-Proto', requestUrl.protocol.replace(':', '')); + } + + // Set X-Forwarded-For to the client IP + // In multi-proxy scenarios, we prefer authoritative headers (CF-Connecting-IP, X-Real-IP) + // over the existing X-Forwarded-For chain, as they provide the true client IP + const clientIp = getClientIp(request); + if (clientIp) { + headers.set('X-Forwarded-For', clientIp); + } + + // Determine if request has a body + const hasBody = ['POST', 'PUT', 'PATCH'].includes(request.method); + + try { + // Make the proxied request + const fetchOptions: RequestInit = { + method: request.method, + headers, + // @ts-expect-error - duplex is required for streaming bodies but not in all TS definitions + duplex: hasBody ? 'half' : undefined, + }; + + // Only include body for methods that support it + if (hasBody && request.body) { + fetchOptions.body = request.body; + } + + const response = await fetch(targetUrl.toString(), fetchOptions); + + // Build response headers, excluding hop-by-hop headers + const responseHeaders = new Headers(); + response.headers.forEach((value, key) => { + if (!HOP_BY_HOP_HEADERS.includes(key.toLowerCase())) { + responseHeaders.set(key, value); + } + }); + + // Rewrite Location header for redirects to go through the proxy + const locationHeader = response.headers.get('location'); + if (locationHeader) { + try { + const locationUrl = new URL(locationHeader, fapiBaseUrl); + // Check if the redirect points to the FAPI host + if (locationUrl.host === fapiHost) { + // Rewrite to go through the proxy + const rewrittenLocation = `${proxyUrl}${locationUrl.pathname}${locationUrl.search}${locationUrl.hash}`; + responseHeaders.set('Location', rewrittenLocation); + } + } catch { + // If URL parsing fails, leave the Location header as-is (could be a relative URL) + } + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + return createErrorResponse('proxy_request_failed', `Failed to proxy request to Clerk FAPI: ${message}`, 502); + } +} diff --git a/packages/backend/tsup.config.ts b/packages/backend/tsup.config.ts index a501053eb06..7731342fb72 100644 --- a/packages/backend/tsup.config.ts +++ b/packages/backend/tsup.config.ts @@ -9,7 +9,7 @@ export default defineConfig(overrideOptions => { const shouldPublish = !!overrideOptions.env?.publish; const common: Options = { - entry: ['src/index.ts', 'src/errors.ts', 'src/internal.ts', 'src/jwt/index.ts', 'src/webhooks.ts'], + entry: ['src/index.ts', 'src/errors.ts', 'src/internal.ts', 'src/jwt/index.ts', 'src/webhooks.ts', 'src/proxy.ts'], onSuccess: `cpy 'src/runtime/**/*.{mjs,js,cjs}' dist/runtime`, sourcemap: true, define: { diff --git a/packages/express/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/express/src/__tests__/__snapshots__/exports.test.ts.snap index 973240cca2e..ac08848e622 100644 --- a/packages/express/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/express/src/__tests__/__snapshots__/exports.test.ts.snap @@ -11,15 +11,3 @@ exports[`module exports > should not change unless explicitly set 1`] = ` "verifyToken", ] `; - -exports[`module exports should not change unless explicitly set 1`] = ` -[ - "authenticateRequest", - "clerkClient", - "clerkMiddleware", - "createClerkClient", - "getAuth", - "requireAuth", - "verifyToken", -] -`; diff --git a/packages/express/src/__tests__/clerkMiddleware.test.ts b/packages/express/src/__tests__/clerkMiddleware.test.ts index 0b70977fa9b..572169cdd23 100644 --- a/packages/express/src/__tests__/clerkMiddleware.test.ts +++ b/packages/express/src/__tests__/clerkMiddleware.test.ts @@ -3,7 +3,7 @@ import { vi } from 'vitest'; import { clerkMiddleware } from '../clerkMiddleware'; import { getAuth } from '../getAuth'; -import { assertNoDebugHeaders, assertSignedOutDebugHeaders, runMiddleware } from './helpers'; +import { assertNoDebugHeaders, assertSignedOutDebugHeaders, runMiddleware, runMiddlewareOnPath } from './helpers'; describe('clerkMiddleware', () => { // TODO(dimkl): Fix issue that makes test order matter, until then keep this test suite first @@ -115,6 +115,58 @@ describe('clerkMiddleware', () => { assertNoDebugHeaders(response); }); + describe('Frontend API proxy handling', () => { + it('authenticates default path when custom proxy path is set', async () => { + // When using a custom path, the default /__clerk should be authenticated + const response = await runMiddlewareOnPath( + clerkMiddleware({ frontendApiProxy: { enabled: true, path: '/custom-clerk-proxy' } }), + '/__clerk/v1/client', + { + Cookie: '__client_uat=1711618859;', + 'Sec-Fetch-Dest': 'document', + }, + ).expect(307); + + expect(response.header).toHaveProperty('x-clerk-auth-status', 'handshake'); + }); + + it('authenticates proxy paths when enabled is false', async () => { + const response = await runMiddlewareOnPath( + clerkMiddleware({ frontendApiProxy: { enabled: false } }), + '/__clerk/v1/client', + { + Cookie: '__client_uat=1711618859;', + 'Sec-Fetch-Dest': 'document', + }, + ).expect(307); + + expect(response.header).toHaveProperty('x-clerk-auth-status', 'handshake'); + }); + + it('does not handle proxy paths when frontendApiProxy is not configured', async () => { + // Without frontendApiProxy option, proxy paths go through normal auth + const response = await runMiddlewareOnPath(clerkMiddleware(), '/__clerk/v1/client', { + Cookie: '__client_uat=1711618859;', + 'Sec-Fetch-Dest': 'document', + }).expect(307); + + expect(response.header).toHaveProperty('x-clerk-auth-status', 'handshake'); + }); + + it('still authenticates requests to other paths when proxy is configured', async () => { + const response = await runMiddlewareOnPath( + clerkMiddleware({ frontendApiProxy: { enabled: true } }), + '/api/users', + { + Cookie: '__client_uat=1711618859;', + 'Sec-Fetch-Dest': 'document', + }, + ).expect(307); + + expect(response.header).toHaveProperty('x-clerk-auth-status', 'handshake'); + }); + }); + it('calls next with an error when request URL is invalid', () => { const req = { url: '//', diff --git a/packages/express/src/__tests__/helpers.ts b/packages/express/src/__tests__/helpers.ts index f4674af2c5b..99a8d10da01 100644 --- a/packages/express/src/__tests__/helpers.ts +++ b/packages/express/src/__tests__/helpers.ts @@ -15,6 +15,18 @@ export function runMiddleware(middleware: RequestHandler | RequestHandler[], hea return supertest(app).get('/').set(headers); } +export function runMiddlewareOnPath( + middleware: RequestHandler | RequestHandler[], + path: string, + headers: Record = {}, +) { + const app: Application = express(); + app.use(middleware); + app.use((_req, res, _next) => res.end('Hello world!')); + + return supertest(app).get(path).set(headers); +} + export function mockResponse(): ExpressResponse { return { status: vi.fn().mockReturnThis(), diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts index 4788760ae98..1706c147481 100644 --- a/packages/express/src/authenticateRequest.ts +++ b/packages/express/src/authenticateRequest.ts @@ -1,5 +1,8 @@ +import { Readable } from 'stream'; + import type { RequestState } from '@clerk/backend/internal'; import { AuthStatus, createClerkRequest } from '@clerk/backend/internal'; +import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH } from '@clerk/backend/proxy'; import { deprecated } from '@clerk/shared/deprecated'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { isHttpOrHttps, isProxyUrlRelative, isValidProxyUrl } from '@clerk/shared/proxy'; @@ -9,7 +12,7 @@ import type { RequestHandler, Response } from 'express'; import { clerkClient as defaultClerkClient } from './clerkClient'; import { satelliteAndMissingProxyUrlAndDomain, satelliteAndMissingSignInUrl } from './errors'; import type { AuthenticateRequestParams, ClerkMiddlewareOptions, ExpressRequestWithAuth } from './types'; -import { incomingMessageToRequest, loadApiEnv, loadClientEnv } from './utils'; +import { incomingMessageToRequest, loadApiEnv, loadClientEnv, requestToProxyRequest } from './utils'; /** * @internal @@ -101,17 +104,79 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = const clerkClient = options.clerkClient || defaultClerkClient; const enableHandshake = options.enableHandshake ?? true; + // Extract proxy configuration + const frontendApiProxy = options.frontendApiProxy; + const proxyEnabled = frontendApiProxy?.enabled === true; + const proxyPath = (frontendApiProxy?.path ?? DEFAULT_PROXY_PATH).replace(/\/+$/, ''); + // eslint-disable-next-line @typescript-eslint/no-misused-promises const middleware: RequestHandler = async (request, response, next) => { if ((request as ExpressRequestWithAuth).auth) { return next(); } + const env = { ...loadApiEnv(), ...loadClientEnv() }; + const publishableKey = options.publishableKey || env.publishableKey; + const secretKey = options.secretKey || env.secretKey; + + // Handle Frontend API proxy requests early, before authentication + if (proxyEnabled) { + const requestPath = request.originalUrl || request.url; + if (requestPath === proxyPath || requestPath.startsWith(proxyPath + '/')) { + // Convert Express request to Fetch API Request + const proxyRequest = requestToProxyRequest(request); + + // Call the core proxy function + const proxyResponse = await clerkFrontendApiProxy(proxyRequest, { + proxyPath, + publishableKey, + secretKey, + }); + + // Send the proxy response back to the client + response.status(proxyResponse.status); + proxyResponse.headers.forEach((value, key) => { + response.setHeader(key, value); + }); + + if (proxyResponse.body) { + const reader = proxyResponse.body.getReader(); + const stream = new Readable({ + async read() { + try { + const { done, value } = await reader.read(); + if (done) { + this.push(null); + } else { + this.push(Buffer.from(value)); + } + } catch (error) { + this.destroy(error instanceof Error ? error : new Error(String(error))); + } + }, + }); + stream.pipe(response); + } else { + response.end(); + } + return; + } + } + + // Auto-derive proxyUrl from frontendApiProxy config if not explicitly set + let resolvedOptions = options; + if (proxyEnabled && !options.proxyUrl) { + const protocol = request.secure || request.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'; + const host = request.headers['x-forwarded-host'] || request.headers.host || 'localhost'; + const derivedProxyUrl = `${protocol}://${host}${proxyPath}`; + resolvedOptions = { ...options, proxyUrl: derivedProxyUrl }; + } + try { const requestState = await authenticateRequest({ clerkClient, request, - options, + options: resolvedOptions, }); if (enableHandshake) { diff --git a/packages/express/src/types.ts b/packages/express/src/types.ts index ac9f074cb4e..d0f7ba79ffb 100644 --- a/packages/express/src/types.ts +++ b/packages/express/src/types.ts @@ -7,6 +7,23 @@ export type ExpressRequestWithAuth = ExpressRequest & { auth: (options?: PendingSessionOptions) => SignedInAuthObject | SignedOutAuthObject; }; +/** + * Options for configuring Frontend API proxy in clerkMiddleware + */ +export interface FrontendApiProxyOptions { + /** + * Enable Frontend API proxy handling. When true, requests to the proxy path + * will be proxied to Clerk's Frontend API and the proxyUrl will be auto-derived. + */ + enabled?: boolean; + /** + * The path prefix for proxy requests. + * + * @default '/__clerk' + */ + path?: string; +} + export type ClerkMiddlewareOptions = AuthenticateRequestOptions & { debug?: boolean; clerkClient?: ClerkClient; @@ -18,6 +35,24 @@ export type ClerkMiddlewareOptions = AuthenticateRequestOptions & { * @default true */ enableHandshake?: boolean; + /** + * Configure Frontend API proxy handling. When set, requests to the proxy path + * will skip authentication, and the proxyUrl will be automatically derived + * for handshake redirects. + * + * @example + * // Enable with defaults (path: '/__clerk') + * clerkMiddleware({ frontendApiProxy: { enabled: true } }) + * + * @example + * // Custom path + * clerkMiddleware({ frontendApiProxy: { enabled: true, path: '/my-proxy' } }) + * + * @example + * // Disable proxy handling + * clerkMiddleware({ frontendApiProxy: { enabled: false } }) + */ + frontendApiProxy?: FrontendApiProxyOptions; }; type ClerkClient = ReturnType; diff --git a/packages/express/src/utils.ts b/packages/express/src/utils.ts index 92664fa8280..662b490db93 100644 --- a/packages/express/src/utils.ts +++ b/packages/express/src/utils.ts @@ -1,3 +1,5 @@ +import { Readable } from 'stream'; + import { isTruthy } from '@clerk/shared/underscore'; import type { Request as ExpressRequest } from 'express'; @@ -51,3 +53,30 @@ export const incomingMessageToRequest = (req: ExpressRequest): Request => { headers: new Headers(headers), }); }; + +/** + * Converts an Express request to a Fetch API Request with body streaming support. + * This is used for proxying requests where the body needs to be forwarded. + */ +export const requestToProxyRequest = (req: ExpressRequest): Request => { + const headers = new Headers(); + Object.entries(req.headers).forEach(([key, value]) => { + if (value) { + headers.set(key, Array.isArray(value) ? value.join(', ') : value); + } + }); + + const protocol = req.protocol || (req.secure ? 'https' : 'http'); + const host = req.get('host') || 'localhost'; + const url = new URL(req.originalUrl || req.url, `${protocol}://${host}`); + + const hasBody = ['POST', 'PUT', 'PATCH'].includes(req.method); + + return new Request(url.toString(), { + method: req.method, + headers, + body: hasBody ? (Readable.toWeb(req) as ReadableStream) : undefined, + // @ts-expect-error - duplex required for streaming bodies but not in all TS definitions + duplex: hasBody ? 'half' : undefined, + }); +}; diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 3a73a657aba..d5e1997d14c 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -59,6 +59,11 @@ "types": "./dist/types/legacy.d.ts", "import": "./dist/esm/legacy.js", "require": "./dist/cjs/legacy.js" + }, + "./proxy": { + "types": "./dist/types/proxy.d.ts", + "import": "./dist/esm/proxy.js", + "require": "./dist/cjs/proxy.js" } }, "types": "./dist/types/index.d.ts", diff --git a/packages/nextjs/src/proxy.ts b/packages/nextjs/src/proxy.ts new file mode 100644 index 00000000000..b109e939ea1 --- /dev/null +++ b/packages/nextjs/src/proxy.ts @@ -0,0 +1,112 @@ +import { + clerkFrontendApiProxy as backendProxy, + DEFAULT_PROXY_PATH, + type FrontendApiProxyOptions, +} from '@clerk/backend/proxy'; + +import { PUBLISHABLE_KEY, SECRET_KEY } from './server/constants'; + +export { DEFAULT_PROXY_PATH, type FrontendApiProxyOptions } from '@clerk/backend/proxy'; + +/** + * Options for the Next.js Frontend API proxy + */ +export interface NextFrontendApiProxyOptions extends Omit { + /** + * The path prefix for proxy requests. For App Router route handlers, + * this is typically derived from the route path. + */ + proxyPath?: string; +} + +/** + * Proxies a request to Clerk's Frontend API in Next.js App Router. + * + * This function handles forwarding requests from your application to Clerk's + * Frontend API, enabling scenarios where direct communication with Clerk's API + * is blocked or needs to go through your application server. + * + * @param request - The incoming Next.js request + * @param options - Proxy configuration options + * @returns A Response from Clerk's Frontend API + * + * @example + * ```typescript + * // app/api/__clerk/[[...path]]/route.ts + * import { clerkFrontendApiProxy } from '@clerk/nextjs/proxy'; + * + * export async function GET(request: Request) { + * return clerkFrontendApiProxy(request); + * } + * + * export async function POST(request: Request) { + * return clerkFrontendApiProxy(request); + * } + * ``` + */ +export async function clerkFrontendApiProxy( + request: Request, + options?: NextFrontendApiProxyOptions, +): Promise { + return backendProxy(request, { + proxyPath: options?.proxyPath || DEFAULT_PROXY_PATH, + publishableKey: options?.publishableKey || PUBLISHABLE_KEY, + secretKey: options?.secretKey || SECRET_KEY, + }); +} + +/** + * Route handler type for Next.js App Router + */ +type RouteHandler = (request: Request) => Promise; + +/** + * Collection of route handlers for all HTTP methods + */ +export interface FrontendApiProxyHandlers { + GET: RouteHandler; + POST: RouteHandler; + PUT: RouteHandler; + DELETE: RouteHandler; + PATCH: RouteHandler; +} + +/** + * Creates route handlers for proxying Clerk Frontend API requests. + * + * This function returns an object with handlers for GET, POST, PUT, DELETE, and PATCH + * methods that can be directly exported from a Next.js App Router route file. + * + * @param options - Proxy configuration options + * @returns An object with route handlers for all HTTP methods + * + * @example + * ```typescript + * // app/api/__clerk/[[...path]]/route.ts + * import { createFrontendApiProxyHandlers } from '@clerk/nextjs/proxy'; + * + * export const { GET, POST, PUT, DELETE, PATCH } = createFrontendApiProxyHandlers(); + * ``` + * + * @example + * ```typescript + * // With custom options + * export const { GET, POST, PUT, DELETE, PATCH } = createFrontendApiProxyHandlers({ + * publishableKey: 'pk_...', + * secretKey: 'sk_...', + * }); + * ``` + */ +export function createFrontendApiProxyHandlers(options?: NextFrontendApiProxyOptions): FrontendApiProxyHandlers { + const handler: RouteHandler = async (request: Request) => { + return clerkFrontendApiProxy(request, options); + }; + + return { + GET: handler, + POST: handler, + PUT: handler, + DELETE: handler, + PATCH: handler, + }; +} diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 69419e2d504..56047c5eaaa 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -937,3 +937,230 @@ describe('Dev Browser JWT when redirecting to cross origin for page requests', f expect((await clerkClient()).authenticateRequest).toBeCalled(); }); }); + +describe('frontendApiProxy multi-domain support', () => { + it('calls proxy when enabled is true and path matches', async () => { + const req = mockRequest({ url: '/__clerk/v1/client' }); + + const resp = await clerkMiddleware({ + frontendApiProxy: { enabled: true }, + })(req, {} as NextFetchEvent); + + // Proxy should intercept the request - we expect a response (not standard middleware response) + // The proxy returns a response without going through authenticateRequest + expect((await clerkClient()).authenticateRequest).not.toBeCalled(); + expect(resp).toBeDefined(); + }); + + it('does not call proxy when enabled is false', async () => { + const req = mockRequest({ url: '/__clerk/v1/client' }); + + const resp = await clerkMiddleware({ + frontendApiProxy: { enabled: false }, + })(req, {} as NextFetchEvent); + + // Request should pass through to normal auth flow + expect((await clerkClient()).authenticateRequest).toBeCalled(); + expect(resp?.status).toEqual(200); + }); + + it('calls proxy when enabled function returns true for the request URL', async () => { + const shouldProxy = vi.fn((url: URL) => url.hostname.endsWith('.replit.app')); + const req = new NextRequest('https://myapp.replit.app/__clerk/v1/client'); + + const resp = await clerkMiddleware({ + frontendApiProxy: { + enabled: shouldProxy, + }, + })(req, {} as NextFetchEvent); + + expect(shouldProxy).toHaveBeenCalledWith(expect.any(URL)); + expect(shouldProxy.mock.calls[0]![0].hostname).toBe('myapp.replit.app'); + expect((await clerkClient()).authenticateRequest).not.toBeCalled(); + expect(resp).toBeDefined(); + }); + + it('does not call proxy when enabled function returns false for the request URL', async () => { + const shouldProxy = vi.fn((url: URL) => url.hostname.endsWith('.replit.app')); + const req = new NextRequest('https://myapp.com/__clerk/v1/client'); + + const resp = await clerkMiddleware({ + frontendApiProxy: { + enabled: shouldProxy, + }, + })(req, {} as NextFetchEvent); + + expect(shouldProxy).toHaveBeenCalledWith(expect.any(URL)); + expect(shouldProxy.mock.calls[0]![0].hostname).toBe('myapp.com'); + // Request should pass through to normal auth flow + expect((await clerkClient()).authenticateRequest).toBeCalled(); + expect(resp?.status).toEqual(200); + }); + + it('uses custom path when provided', async () => { + const req = mockRequest({ url: '/custom-proxy/v1/client' }); + + const resp = await clerkMiddleware({ + frontendApiProxy: { + enabled: true, + path: '/custom-proxy', + }, + })(req, {} as NextFetchEvent); + + // Proxy should intercept the request with custom path + expect((await clerkClient()).authenticateRequest).not.toBeCalled(); + expect(resp).toBeDefined(); + }); + + it('does not match default path when custom path is provided', async () => { + const req = mockRequest({ url: '/__clerk/v1/client' }); + + const resp = await clerkMiddleware({ + frontendApiProxy: { + enabled: true, + path: '/custom-proxy', + }, + })(req, {} as NextFetchEvent); + + // Request should pass through to normal auth flow since path doesn't match + expect((await clerkClient()).authenticateRequest).toBeCalled(); + expect(resp?.status).toEqual(200); + }); + + it('uses default /__clerk path when path is not specified', async () => { + const req = mockRequest({ url: '/__clerk/v1/client' }); + + const resp = await clerkMiddleware({ + frontendApiProxy: { enabled: true }, + })(req, {} as NextFetchEvent); + + // Proxy should intercept the request with default path + expect((await clerkClient()).authenticateRequest).not.toBeCalled(); + expect(resp).toBeDefined(); + }); + + it('correctly filters by multiple domain suffixes', async () => { + const PROXY_DOMAINS = ['.replit.app', '.replit.dev', '.vercel.app']; + const shouldProxy = (url: URL) => PROXY_DOMAINS.some(suffix => url.hostname.endsWith(suffix)); + + // Test replit.app - should proxy + const req1 = new NextRequest('https://myapp.replit.app/__clerk/v1/client'); + await clerkMiddleware({ frontendApiProxy: { enabled: shouldProxy } })(req1, {} as NextFetchEvent); + expect((await clerkClient()).authenticateRequest).not.toBeCalled(); + + vi.mocked(clerkClient).mockClear(); + vi.mocked(clerkClient).mockResolvedValue({ + authenticateRequest: authenticateRequestMock, + // @ts-expect-error - mock + telemetry: { record: vi.fn() }, + }); + + // Test vercel.app - should proxy + const req2 = new NextRequest('https://myapp.vercel.app/__clerk/v1/client'); + await clerkMiddleware({ frontendApiProxy: { enabled: shouldProxy } })(req2, {} as NextFetchEvent); + expect((await clerkClient()).authenticateRequest).not.toBeCalled(); + + vi.mocked(clerkClient).mockClear(); + vi.mocked(clerkClient).mockResolvedValue({ + authenticateRequest: authenticateRequestMock, + // @ts-expect-error - mock + telemetry: { record: vi.fn() }, + }); + + // Test custom domain - should not proxy + const req3 = new NextRequest('https://myapp.com/__clerk/v1/client'); + await clerkMiddleware({ frontendApiProxy: { enabled: shouldProxy } })(req3, {} as NextFetchEvent); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + + it('supports proxy everywhere except production pattern', async () => { + const shouldProxy = (url: URL) => { + // Don't proxy on production domain + if (url.hostname === 'myapp.com' || url.hostname === 'www.myapp.com') { + return false; + } + // Proxy everywhere else (staging, preview, dev) + return true; + }; + + // Test production - should not proxy + const req1 = new NextRequest('https://myapp.com/__clerk/v1/client'); + await clerkMiddleware({ frontendApiProxy: { enabled: shouldProxy } })(req1, {} as NextFetchEvent); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + + vi.mocked(clerkClient).mockClear(); + vi.mocked(clerkClient).mockResolvedValue({ + authenticateRequest: authenticateRequestMock, + // @ts-expect-error - mock + telemetry: { record: vi.fn() }, + }); + + // Test staging - should proxy + const req2 = new NextRequest('https://staging.myapp.com/__clerk/v1/client'); + await clerkMiddleware({ frontendApiProxy: { enabled: shouldProxy } })(req2, {} as NextFetchEvent); + expect((await clerkClient()).authenticateRequest).not.toBeCalled(); + }); + + it('auto-derives proxyUrl from frontendApiProxy config for handshake redirects', async () => { + // Request to a non-proxy path should go through auth with derived proxyUrl + const req = new NextRequest('https://myapp.example.com/dashboard'); + + await clerkMiddleware({ + frontendApiProxy: { enabled: true, path: '/__clerk' }, + })(req, {} as NextFetchEvent); + + // authenticateRequest should be called with the derived proxyUrl + expect((await clerkClient()).authenticateRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + proxyUrl: 'https://myapp.example.com/__clerk', + }), + ); + }); + + it('auto-derives proxyUrl with custom proxy path', async () => { + const req = new NextRequest('https://myapp.example.com/dashboard'); + + await clerkMiddleware({ + frontendApiProxy: { enabled: true, path: '/custom-clerk-proxy' }, + })(req, {} as NextFetchEvent); + + expect((await clerkClient()).authenticateRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + proxyUrl: 'https://myapp.example.com/custom-clerk-proxy', + }), + ); + }); + + it('does not derive proxyUrl when frontendApiProxy is not configured', async () => { + const req = new NextRequest('https://myapp.example.com/dashboard'); + + await clerkMiddleware()(req, {} as NextFetchEvent); + + // authenticateRequest should be called without proxyUrl + expect((await clerkClient()).authenticateRequest).toHaveBeenCalledWith( + expect.anything(), + expect.not.objectContaining({ + proxyUrl: expect.any(String), + }), + ); + }); + + it('does not override explicit proxyUrl option', async () => { + const req = new NextRequest('https://myapp.example.com/dashboard'); + + await clerkMiddleware({ + frontendApiProxy: { enabled: true, path: '/__clerk' }, + proxyUrl: 'https://custom-proxy.example.com/__clerk', + })(req, {} as NextFetchEvent); + + // Should use the explicit proxyUrl, not the derived one + expect((await clerkClient()).authenticateRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + proxyUrl: 'https://custom-proxy.example.com/__clerk', + }), + ); + }); +}); diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 671c034af63..c0840f744d3 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -20,6 +20,7 @@ import { makeAuthObjectSerializable, TokenType, } from '@clerk/backend/internal'; +import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath } from '@clerk/backend/proxy'; import { parsePublishableKey } from '@clerk/shared/keys'; import { notFound as nextjsNotFound } from 'next/navigation'; import type { NextMiddleware, NextRequest } from 'next/server'; @@ -50,7 +51,12 @@ import { } from './nextErrors'; import type { AuthProtect } from './protect'; import { createProtect } from './protect'; -import type { NextMiddlewareEvtParam, NextMiddlewareRequestParam, NextMiddlewareReturn } from './types'; +import type { + FrontendApiProxyOptions, + NextMiddlewareEvtParam, + NextMiddlewareRequestParam, + NextMiddlewareReturn, +} from './types'; import { assertKey, decorateRequest, @@ -88,6 +94,12 @@ export interface ClerkMiddlewareOptions extends AuthenticateAnyRequestOptions { * When set, automatically injects a Content-Security-Policy header(s) compatible with Clerk. */ contentSecurityPolicy?: ContentSecurityPolicyOptions; + + /** + * When set, enables the middleware to proxy Frontend API requests to Clerk. + * This is useful when direct communication with Clerk's API is blocked. + */ + frontendApiProxy?: FrontendApiProxyOptions; } type ClerkMiddlewareOptionsCallback = (req: NextRequest) => ClerkMiddlewareOptions | Promise; @@ -144,6 +156,25 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl const secretKey = assertKey(resolvedParams.secretKey || SECRET_KEY || keyless?.secretKey, () => errorThrower.throwMissingSecretKeyError(), ); + + // Handle Frontend API proxy requests early, before authentication + const frontendApiProxyConfig = resolvedParams.frontendApiProxy; + if (frontendApiProxyConfig) { + const { enabled, path: proxyPath = DEFAULT_PROXY_PATH } = frontendApiProxyConfig; + + // Resolve enabled - either boolean or function + const requestUrl = new URL(request.url); + const isEnabled = typeof enabled === 'function' ? enabled(requestUrl) : enabled; + + if (isEnabled && matchProxyPath(request, { proxyPath })) { + return clerkFrontendApiProxy(request, { + proxyPath, + publishableKey, + secretKey, + }); + } + } + const signInUrl = resolvedParams.signInUrl || SIGN_IN_URL; const signUpUrl = resolvedParams.signUpUrl || SIGN_UP_URL; @@ -362,9 +393,22 @@ export const createAuthenticateRequestOptions = ( clerkRequest: ClerkRequest, options: ClerkMiddlewareOptions, ): Parameters[1] => { + // Auto-derive proxyUrl from frontendApiProxy config if not explicitly set + let resolvedOptions = options; + if (options.frontendApiProxy && !options.proxyUrl) { + const { enabled, path: proxyPath = DEFAULT_PROXY_PATH } = options.frontendApiProxy; + const requestUrl = new URL(clerkRequest.url); + const isEnabled = typeof enabled === 'function' ? enabled(requestUrl) : enabled; + + if (isEnabled) { + const derivedProxyUrl = `${requestUrl.origin}${proxyPath}`; + resolvedOptions = { ...options, proxyUrl: derivedProxyUrl }; + } + } + return { - ...options, - ...handleMultiDomainAndProxy(clerkRequest, options), + ...resolvedOptions, + ...handleMultiDomainAndProxy(clerkRequest, resolvedOptions), // TODO: Leaving the acceptsToken as 'any' opens up the possibility of // an economic attack. We should revisit this and only verify a token // when auth() or auth.protect() is invoked. diff --git a/packages/nextjs/src/server/types.ts b/packages/nextjs/src/server/types.ts index b1f15bc79fd..5fc7a0e2a11 100644 --- a/packages/nextjs/src/server/types.ts +++ b/packages/nextjs/src/server/types.ts @@ -3,6 +3,8 @@ import type { NextApiRequest } from 'next'; import type { NextApiRequestCookies } from 'next/dist/server/api-utils'; import type { NextMiddleware, NextRequest } from 'next/server'; +import type { ShouldProxyFn } from '@clerk/shared/proxy'; + // Request contained in GetServerSidePropsContext, has cookies but not query type GsspRequest = IncomingMessage & { cookies: NextApiRequestCookies }; @@ -11,3 +13,20 @@ export type RequestLike = NextRequest | NextApiRequest | GsspRequest; export type NextMiddlewareRequestParam = Parameters['0']; export type NextMiddlewareEvtParam = Parameters['1']; export type NextMiddlewareReturn = ReturnType; + +/** + * Options for configuring Frontend API proxy in clerkMiddleware + */ +export interface FrontendApiProxyOptions { + /** + * Enable proxy handling. Can be: + * - `true` - enable for all domains + * - `false` - disable for all domains + * - A function: (url: URL) => boolean - enable based on the request URL + */ + enabled: boolean | ShouldProxyFn; + /** + * The path prefix for proxy requests. Defaults to `/__clerk`. + */ + path?: string; +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index bfe137c0052..34917268481 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -17,6 +17,12 @@ export const LOCAL_API_URL = 'https://api.lclclerk.com'; export const STAGING_API_URL = 'https://api.clerkstage.dev'; export const PROD_API_URL = 'https://api.clerk.com'; +export const LOCAL_FAPI_URL = 'https://frontend-api.lclclerk.com'; +export const STAGING_FAPI_URL = 'https://frontend-api.clerkstage.dev'; +export const PROD_FAPI_URL = 'https://frontend-api.clerk.dev'; + +export const DEFAULT_PROXY_PATH = '/__clerk'; + /** * Returns the URL for a static image * using the new img.clerk.com service diff --git a/packages/shared/src/proxy.ts b/packages/shared/src/proxy.ts index adb684861c8..f7633ed1773 100644 --- a/packages/shared/src/proxy.ts +++ b/packages/shared/src/proxy.ts @@ -32,3 +32,8 @@ export function proxyUrlToAbsoluteURL(url: string | undefined): string { } return isProxyUrlRelative(url) ? new URL(url, window.location.origin).toString() : url; } + +/** + * Function that determines whether proxy should be used for a given URL. + */ +export type ShouldProxyFn = (url: URL) => boolean;