From 5ae1734319efae0d6a877da6d7a0c66f0d0e4b52 Mon Sep 17 00:00:00 2001 From: brkalow Date: Wed, 14 Jan 2026 22:38:18 -0600 Subject: [PATCH 01/10] feat: Add Frontend API proxy helpers for @clerk/backend, @clerk/nextjs, and @clerk/express Implement clerkProxy helper that abstracts away the complexity of proxying Clerk's Frontend API (FAPI) requests. This enables scenarios where direct communication with Clerk's API is blocked or needs to go through the application server. - Core proxy implementation in @clerk/backend/src/proxy.ts with environment-aware URL derivation - Next.js integration via clerkMiddleware frontendApiProxy option and route handlers - Express middleware for handling proxy requests with body streaming support - FAPI URL constants added to @clerk/shared for environment detection Co-Authored-By: Claude Haiku 4.5 --- packages/backend/package.json | 13 +- packages/backend/proxy/package.json | 5 + packages/backend/src/proxy.ts | 248 ++++++++++++++++++ packages/backend/tsup.config.ts | 2 +- packages/express/package.json | 10 + packages/express/src/proxy.ts | 99 +++++++ packages/express/src/utils.ts | 29 ++ packages/express/tsup.config.ts | 2 +- packages/nextjs/package.json | 5 + packages/nextjs/src/proxy.ts | 112 ++++++++ packages/nextjs/src/server/clerkMiddleware.ts | 27 +- packages/nextjs/src/server/types.ts | 14 + packages/shared/src/constants.ts | 6 + 13 files changed, 568 insertions(+), 4 deletions(-) create mode 100644 packages/backend/proxy/package.json create mode 100644 packages/backend/src/proxy.ts create mode 100644 packages/express/src/proxy.ts create mode 100644 packages/nextjs/src/proxy.ts 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/proxy.ts b/packages/backend/src/proxy.ts new file mode 100644 index 00000000000..74d19dc9462 --- /dev/null +++ b/packages/backend/src/proxy.ts @@ -0,0 +1,248 @@ +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; + const url = new URL(request.url); + return 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; + 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); + if (!requestUrl.pathname.startsWith(proxyPath)) { + 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 + headers.set('Host', new URL(fapiBaseUrl).host); + + // Forward 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); + } + }); + + 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/package.json b/packages/express/package.json index 8bb09077949..70f870a2bbd 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -43,6 +43,16 @@ "default": "./dist/webhooks.js" } }, + "./proxy": { + "import": { + "types": "./dist/proxy.d.mts", + "default": "./dist/proxy.mjs" + }, + "require": { + "types": "./dist/proxy.d.ts", + "default": "./dist/proxy.js" + } + }, "./env": "./env.d.ts", "./package.json": "./package.json" }, diff --git a/packages/express/src/proxy.ts b/packages/express/src/proxy.ts new file mode 100644 index 00000000000..5bdc14771b9 --- /dev/null +++ b/packages/express/src/proxy.ts @@ -0,0 +1,99 @@ +import { Readable } from 'stream'; + +import { clerkFrontendApiProxy, type FrontendApiProxyOptions } from '@clerk/backend/proxy'; +import type { NextFunction, Request as ExpressRequest, Response as ExpressResponse } from 'express'; + +import { loadApiEnv, loadClientEnv, requestToProxyRequest } from './utils'; + +export { DEFAULT_PROXY_PATH, type FrontendApiProxyOptions } from '@clerk/backend/proxy'; + +/** + * Options for the Express Frontend API proxy middleware + */ +export interface ExpressFrontendApiProxyOptions extends Omit {} + +/** + * Creates Express middleware that proxies requests to Clerk's Frontend API. + * + * This middleware 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 options - Proxy configuration options + * @returns Express middleware handler + * + * @example + * ```typescript + * import express from 'express'; + * import { clerkFrontendApiProxyMiddleware } from '@clerk/express/proxy'; + * + * const app = express(); + * + * // Mount the proxy middleware at /__clerk + * app.use('/__clerk', clerkFrontendApiProxyMiddleware()); + * + * app.listen(3000); + * ``` + * + * @example + * ```typescript + * // With custom options + * app.use('/__clerk', clerkFrontendApiProxyMiddleware({ + * publishableKey: 'pk_...', + * secretKey: 'sk_...', + * })); + * ``` + */ +export function clerkFrontendApiProxyMiddleware(options?: ExpressFrontendApiProxyOptions) { + return async (req: ExpressRequest, res: ExpressResponse, _next: NextFunction) => { + const clientEnv = loadClientEnv(); + const apiEnv = loadApiEnv(); + + const publishableKey = options?.publishableKey || clientEnv.publishableKey; + const secretKey = options?.secretKey || apiEnv.secretKey; + + // Convert Express request to Fetch API Request with body streaming + const request = requestToProxyRequest(req); + + // The proxy path is determined by where this middleware is mounted in Express + // We extract it from the request URL by looking at the baseUrl + const proxyPath = req.baseUrl || '/__clerk'; + + // Call the core proxy function + const response = await clerkFrontendApiProxy(request, { + proxyPath, + publishableKey, + secretKey, + }); + + // Set response status + res.status(response.status); + + // Copy response headers + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + // Stream the response body + if (response.body) { + const reader = response.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(res); + } else { + res.end(); + } + }; +} 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/express/tsup.config.ts b/packages/express/tsup.config.ts index be41714b765..40db93fa587 100644 --- a/packages/express/tsup.config.ts +++ b/packages/express/tsup.config.ts @@ -6,7 +6,7 @@ export default defineConfig(overrideOptions => { const isWatch = !!overrideOptions.watch; return { - entry: ['./src/index.ts', './src/webhooks.ts'], + entry: ['./src/index.ts', './src/webhooks.ts', './src/proxy.ts'], format: ['cjs', 'esm'], bundle: true, clean: true, 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/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 671c034af63..ca70a6e2f6f 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,19 @@ 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 + if (resolvedParams.frontendApiProxy?.enabled) { + const proxyPath = resolvedParams.frontendApiProxy.path || DEFAULT_PROXY_PATH; + if (matchProxyPath(request, { proxyPath })) { + return clerkFrontendApiProxy(request, { + proxyPath, + publishableKey, + secretKey, + }); + } + } + const signInUrl = resolvedParams.signInUrl || SIGN_IN_URL; const signUpUrl = resolvedParams.signUpUrl || SIGN_UP_URL; diff --git a/packages/nextjs/src/server/types.ts b/packages/nextjs/src/server/types.ts index b1f15bc79fd..a7d8b1a9258 100644 --- a/packages/nextjs/src/server/types.ts +++ b/packages/nextjs/src/server/types.ts @@ -11,3 +11,17 @@ 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 in middleware + */ + enabled: boolean; + /** + * 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 From b6e0da7dd5a332beaafcdda61cfebd71924fe990 Mon Sep 17 00:00:00 2001 From: brkalow Date: Wed, 14 Jan 2026 22:44:39 -0600 Subject: [PATCH 02/10] test: Add tests for Frontend API proxy helpers - Add comprehensive tests for @clerk/backend/proxy including FAPI URL derivation, path matching, and request forwarding - Add tests for @clerk/nextjs/proxy route handlers and exports - Add tests for @clerk/express/proxy middleware and request conversion Co-Authored-By: Claude Opus 4.5 --- packages/backend/src/__tests__/proxy.test.ts | 323 +++++++++++++++++++ packages/express/src/__tests__/proxy.test.ts | 299 +++++++++++++++++ packages/nextjs/src/__tests__/proxy.test.ts | 134 ++++++++ 3 files changed, 756 insertions(+) create mode 100644 packages/backend/src/__tests__/proxy.test.ts create mode 100644 packages/express/src/__tests__/proxy.test.ts create mode 100644 packages/nextjs/src/__tests__/proxy.test.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..f537b02c749 --- /dev/null +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -0,0 +1,323 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, fapiUrlFromPublishableKey, matchProxyPath } from '../proxy'; + +describe('proxy', () => { + describe('DEFAULT_PROXY_PATH', () => { + it('should be /__clerk', () => { + expect(DEFAULT_PROXY_PATH).toBe('/__clerk'); + }); + }); + + 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); + }); + }); + + 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('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'); + }); + }); +}); diff --git a/packages/express/src/__tests__/proxy.test.ts b/packages/express/src/__tests__/proxy.test.ts new file mode 100644 index 00000000000..c4d3750fb64 --- /dev/null +++ b/packages/express/src/__tests__/proxy.test.ts @@ -0,0 +1,299 @@ +import type { Application } from 'express'; +import express from 'express'; +import { Readable } from 'stream'; +import supertest from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clerkFrontendApiProxyMiddleware, DEFAULT_PROXY_PATH } from '../proxy'; +import { requestToProxyRequest } from '../utils'; + +// Mock the backend proxy module +vi.mock('@clerk/backend/proxy', () => ({ + clerkFrontendApiProxy: vi.fn(), + DEFAULT_PROXY_PATH: '/__clerk', +})); + +describe('proxy', () => { + describe('DEFAULT_PROXY_PATH', () => { + it('should be /__clerk', () => { + expect(DEFAULT_PROXY_PATH).toBe('/__clerk'); + }); + }); + + describe('clerkFrontendApiProxyMiddleware', () => { + let mockProxy: ReturnType; + + beforeEach(async () => { + const { clerkFrontendApiProxy } = await import('@clerk/backend/proxy'); + mockProxy = vi.mocked(clerkFrontendApiProxy); + mockProxy.mockReset(); + + // Set up environment variables + process.env.CLERK_PUBLISHABLE_KEY = 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k'; + process.env.CLERK_SECRET_KEY = 'sk_test_xxx'; + }); + + afterEach(() => { + delete process.env.CLERK_PUBLISHABLE_KEY; + delete process.env.CLERK_SECRET_KEY; + }); + + function createApp() { + const app: Application = express(); + app.use('/__clerk', clerkFrontendApiProxyMiddleware()); + return app; + } + + it('returns middleware function', () => { + const middleware = clerkFrontendApiProxyMiddleware(); + expect(typeof middleware).toBe('function'); + }); + + it('proxies GET requests', async () => { + mockProxy.mockResolvedValue( + new Response(JSON.stringify({ client: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const app = createApp(); + const response = await supertest(app).get('/__clerk/v1/client'); + + expect(mockProxy).toHaveBeenCalledTimes(1); + expect(response.status).toBe(200); + expect(response.body).toEqual({ client: {} }); + }); + + it('proxies POST requests', async () => { + mockProxy.mockResolvedValue( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const app = createApp(); + const response = await supertest(app).post('/__clerk/v1/sign_ups').send({ email: 'test@example.com' }); + + expect(mockProxy).toHaveBeenCalledTimes(1); + expect(response.status).toBe(200); + }); + + it('passes through error status codes', async () => { + mockProxy.mockResolvedValue( + new Response(JSON.stringify({ errors: [{ message: 'Unauthorized' }] }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const app = createApp(); + const response = await supertest(app).get('/__clerk/v1/client'); + + expect(response.status).toBe(401); + }); + + it('uses baseUrl as proxyPath', async () => { + mockProxy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + const app: Application = express(); + app.use('/custom-proxy', clerkFrontendApiProxyMiddleware()); + + await supertest(app).get('/custom-proxy/v1/client'); + + expect(mockProxy).toHaveBeenCalledWith( + expect.any(Request), + expect.objectContaining({ + proxyPath: '/custom-proxy', + }), + ); + }); + + it('uses custom publishableKey and secretKey when provided', async () => { + mockProxy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + const app: Application = express(); + app.use( + '/__clerk', + clerkFrontendApiProxyMiddleware({ + publishableKey: 'pk_custom', + secretKey: 'sk_custom', + }), + ); + + await supertest(app).get('/__clerk/v1/client'); + + expect(mockProxy).toHaveBeenCalledWith( + expect.any(Request), + expect.objectContaining({ + publishableKey: 'pk_custom', + secretKey: 'sk_custom', + }), + ); + }); + + it('forwards response headers', async () => { + mockProxy.mockResolvedValue( + new Response(JSON.stringify({}), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value', + }, + }), + ); + + const app = createApp(); + const response = await supertest(app).get('/__clerk/v1/client'); + + expect(response.headers['content-type']).toContain('application/json'); + expect(response.headers['x-custom-header']).toBe('custom-value'); + }); + }); + + describe('requestToProxyRequest', () => { + it('converts Express request to Fetch Request', () => { + const mockExpressReq = { + method: 'GET', + protocol: 'https', + secure: true, + originalUrl: '/__clerk/v1/client', + url: '/__clerk/v1/client', + headers: { + host: 'example.com', + 'user-agent': 'Test Agent', + }, + get: (header: string) => { + const headers: Record = { + host: 'example.com', + 'user-agent': 'Test Agent', + }; + return headers[header.toLowerCase()]; + }, + } as any; + + const request = requestToProxyRequest(mockExpressReq); + + expect(request.method).toBe('GET'); + expect(request.url).toBe('https://example.com/__clerk/v1/client'); + expect(request.headers.get('host')).toBe('example.com'); + expect(request.headers.get('user-agent')).toBe('Test Agent'); + }); + + it('includes body for POST requests', () => { + // Create a real Readable stream with Express-like properties + const mockStream = new Readable({ + read() { + this.push(JSON.stringify({ email: 'test@example.com' })); + this.push(null); + }, + }); + + // Add Express-specific properties to the stream + const mockExpressReq = Object.assign(mockStream, { + method: 'POST', + protocol: 'https', + secure: true, + originalUrl: '/__clerk/v1/sign_ups', + url: '/__clerk/v1/sign_ups', + headers: { + host: 'example.com', + 'content-type': 'application/json', + }, + get: (header: string) => { + const headers: Record = { + host: 'example.com', + 'content-type': 'application/json', + }; + return headers[header.toLowerCase()]; + }, + }) as any; + + const request = requestToProxyRequest(mockExpressReq); + + expect(request.method).toBe('POST'); + // Body should be defined for POST + expect(request.body).toBeDefined(); + }); + + it('does not include body for GET requests', () => { + const mockExpressReq = { + method: 'GET', + protocol: 'https', + secure: true, + originalUrl: '/__clerk/v1/client', + url: '/__clerk/v1/client', + headers: { + host: 'example.com', + }, + get: (header: string) => { + const headers: Record = { + host: 'example.com', + }; + return headers[header.toLowerCase()]; + }, + } as any; + + const request = requestToProxyRequest(mockExpressReq); + + expect(request.body).toBeNull(); + }); + + it('handles array header values', () => { + const mockExpressReq = { + method: 'GET', + protocol: 'https', + secure: true, + originalUrl: '/__clerk/v1/client', + url: '/__clerk/v1/client', + headers: { + host: 'example.com', + 'accept-encoding': ['gzip', 'deflate'], + }, + get: (header: string) => { + const headers: Record = { + host: 'example.com', + }; + return headers[header.toLowerCase()]; + }, + } as any; + + const request = requestToProxyRequest(mockExpressReq); + + expect(request.headers.get('accept-encoding')).toBe('gzip, deflate'); + }); + + it('uses http protocol when not secure', () => { + const mockExpressReq = { + method: 'GET', + protocol: 'http', + secure: false, + originalUrl: '/__clerk/v1/client', + url: '/__clerk/v1/client', + headers: { + host: 'localhost:3000', + }, + get: (header: string) => { + const headers: Record = { + host: 'localhost:3000', + }; + return headers[header.toLowerCase()]; + }, + } as any; + + const request = requestToProxyRequest(mockExpressReq); + + expect(request.url).toBe('http://localhost:3000/__clerk/v1/client'); + }); + }); + + describe('exports', () => { + it('exports all required functions and constants', async () => { + const proxyModule = await import('../proxy'); + + expect(proxyModule).toHaveProperty('clerkFrontendApiProxyMiddleware'); + expect(proxyModule).toHaveProperty('DEFAULT_PROXY_PATH'); + }); + }); +}); diff --git a/packages/nextjs/src/__tests__/proxy.test.ts b/packages/nextjs/src/__tests__/proxy.test.ts new file mode 100644 index 00000000000..1a0f047e3b8 --- /dev/null +++ b/packages/nextjs/src/__tests__/proxy.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clerkFrontendApiProxy, createFrontendApiProxyHandlers, DEFAULT_PROXY_PATH } from '../proxy'; + +// Mock the backend proxy module +vi.mock('@clerk/backend/proxy', () => ({ + clerkFrontendApiProxy: vi.fn(), + DEFAULT_PROXY_PATH: '/__clerk', + matchProxyPath: vi.fn(), +})); + +// Mock the server constants +vi.mock('../server/constants', () => ({ + PUBLISHABLE_KEY: 'pk_test_mock', + SECRET_KEY: 'sk_test_mock', +})); + +describe('proxy', () => { + describe('DEFAULT_PROXY_PATH', () => { + it('should be /__clerk', () => { + expect(DEFAULT_PROXY_PATH).toBe('/__clerk'); + }); + }); + + describe('createFrontendApiProxyHandlers', () => { + it('returns handlers for all HTTP methods', () => { + const handlers = createFrontendApiProxyHandlers(); + + expect(handlers).toHaveProperty('GET'); + expect(handlers).toHaveProperty('POST'); + expect(handlers).toHaveProperty('PUT'); + expect(handlers).toHaveProperty('DELETE'); + expect(handlers).toHaveProperty('PATCH'); + + expect(typeof handlers.GET).toBe('function'); + expect(typeof handlers.POST).toBe('function'); + expect(typeof handlers.PUT).toBe('function'); + expect(typeof handlers.DELETE).toBe('function'); + expect(typeof handlers.PATCH).toBe('function'); + }); + + it('all handlers call the same underlying function', () => { + const handlers = createFrontendApiProxyHandlers(); + + // All handlers should be the same function + expect(handlers.GET).toBe(handlers.POST); + expect(handlers.POST).toBe(handlers.PUT); + expect(handlers.PUT).toBe(handlers.DELETE); + expect(handlers.DELETE).toBe(handlers.PATCH); + }); + + it('passes custom options to proxy function', async () => { + const { clerkFrontendApiProxy: mockProxy } = await import('@clerk/backend/proxy'); + const mockedProxy = vi.mocked(mockProxy); + + mockedProxy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + const customOptions = { + publishableKey: 'pk_custom', + secretKey: 'sk_custom', + }; + + const handlers = createFrontendApiProxyHandlers(customOptions); + const mockRequest = new Request('https://example.com/__clerk/v1/client'); + + await handlers.GET(mockRequest); + + expect(mockedProxy).toHaveBeenCalledWith(mockRequest, expect.objectContaining(customOptions)); + }); + }); + + describe('clerkFrontendApiProxy', () => { + let mockBackendProxy: ReturnType; + + beforeEach(async () => { + const { clerkFrontendApiProxy: backendProxy } = await import('@clerk/backend/proxy'); + mockBackendProxy = vi.mocked(backendProxy); + mockBackendProxy.mockReset(); + }); + + it('calls backend proxy with default options', async () => { + mockBackendProxy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + const request = new Request('https://example.com/__clerk/v1/client'); + await clerkFrontendApiProxy(request); + + expect(mockBackendProxy).toHaveBeenCalledWith(request, { + proxyPath: '/__clerk', + publishableKey: 'pk_test_mock', + secretKey: 'sk_test_mock', + }); + }); + + it('allows overriding options', async () => { + mockBackendProxy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + const request = new Request('https://example.com/custom/__clerk/v1/client'); + await clerkFrontendApiProxy(request, { + proxyPath: '/custom/__clerk', + publishableKey: 'pk_custom', + secretKey: 'sk_custom', + }); + + expect(mockBackendProxy).toHaveBeenCalledWith(request, { + proxyPath: '/custom/__clerk', + publishableKey: 'pk_custom', + secretKey: 'sk_custom', + }); + }); + + it('returns response from backend proxy', async () => { + const expectedResponse = new Response(JSON.stringify({ client: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + mockBackendProxy.mockResolvedValue(expectedResponse); + + const request = new Request('https://example.com/__clerk/v1/client'); + const response = await clerkFrontendApiProxy(request); + + expect(response).toBe(expectedResponse); + }); + }); + + describe('exports', () => { + it('exports all required functions and constants', async () => { + const proxyModule = await import('../proxy.js'); + + expect(proxyModule).toHaveProperty('clerkFrontendApiProxy'); + expect(proxyModule).toHaveProperty('createFrontendApiProxyHandlers'); + expect(proxyModule).toHaveProperty('DEFAULT_PROXY_PATH'); + }); + }); +}); From 04ca6261affbdb7c214e478945e51a3d3698ac46 Mon Sep 17 00:00:00 2001 From: brkalow Date: Wed, 14 Jan 2026 22:50:39 -0600 Subject: [PATCH 03/10] test: Remove low-value Next.js proxy tests The Next.js proxy tests mocked the underlying @clerk/backend/proxy, making them essentially test that wrapper A calls function B - no real behavior was verified. The backend proxy tests provide actual coverage. Co-Authored-By: Claude Opus 4.5 --- packages/nextjs/src/__tests__/proxy.test.ts | 134 -------------------- 1 file changed, 134 deletions(-) delete mode 100644 packages/nextjs/src/__tests__/proxy.test.ts diff --git a/packages/nextjs/src/__tests__/proxy.test.ts b/packages/nextjs/src/__tests__/proxy.test.ts deleted file mode 100644 index 1a0f047e3b8..00000000000 --- a/packages/nextjs/src/__tests__/proxy.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { clerkFrontendApiProxy, createFrontendApiProxyHandlers, DEFAULT_PROXY_PATH } from '../proxy'; - -// Mock the backend proxy module -vi.mock('@clerk/backend/proxy', () => ({ - clerkFrontendApiProxy: vi.fn(), - DEFAULT_PROXY_PATH: '/__clerk', - matchProxyPath: vi.fn(), -})); - -// Mock the server constants -vi.mock('../server/constants', () => ({ - PUBLISHABLE_KEY: 'pk_test_mock', - SECRET_KEY: 'sk_test_mock', -})); - -describe('proxy', () => { - describe('DEFAULT_PROXY_PATH', () => { - it('should be /__clerk', () => { - expect(DEFAULT_PROXY_PATH).toBe('/__clerk'); - }); - }); - - describe('createFrontendApiProxyHandlers', () => { - it('returns handlers for all HTTP methods', () => { - const handlers = createFrontendApiProxyHandlers(); - - expect(handlers).toHaveProperty('GET'); - expect(handlers).toHaveProperty('POST'); - expect(handlers).toHaveProperty('PUT'); - expect(handlers).toHaveProperty('DELETE'); - expect(handlers).toHaveProperty('PATCH'); - - expect(typeof handlers.GET).toBe('function'); - expect(typeof handlers.POST).toBe('function'); - expect(typeof handlers.PUT).toBe('function'); - expect(typeof handlers.DELETE).toBe('function'); - expect(typeof handlers.PATCH).toBe('function'); - }); - - it('all handlers call the same underlying function', () => { - const handlers = createFrontendApiProxyHandlers(); - - // All handlers should be the same function - expect(handlers.GET).toBe(handlers.POST); - expect(handlers.POST).toBe(handlers.PUT); - expect(handlers.PUT).toBe(handlers.DELETE); - expect(handlers.DELETE).toBe(handlers.PATCH); - }); - - it('passes custom options to proxy function', async () => { - const { clerkFrontendApiProxy: mockProxy } = await import('@clerk/backend/proxy'); - const mockedProxy = vi.mocked(mockProxy); - - mockedProxy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); - - const customOptions = { - publishableKey: 'pk_custom', - secretKey: 'sk_custom', - }; - - const handlers = createFrontendApiProxyHandlers(customOptions); - const mockRequest = new Request('https://example.com/__clerk/v1/client'); - - await handlers.GET(mockRequest); - - expect(mockedProxy).toHaveBeenCalledWith(mockRequest, expect.objectContaining(customOptions)); - }); - }); - - describe('clerkFrontendApiProxy', () => { - let mockBackendProxy: ReturnType; - - beforeEach(async () => { - const { clerkFrontendApiProxy: backendProxy } = await import('@clerk/backend/proxy'); - mockBackendProxy = vi.mocked(backendProxy); - mockBackendProxy.mockReset(); - }); - - it('calls backend proxy with default options', async () => { - mockBackendProxy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); - - const request = new Request('https://example.com/__clerk/v1/client'); - await clerkFrontendApiProxy(request); - - expect(mockBackendProxy).toHaveBeenCalledWith(request, { - proxyPath: '/__clerk', - publishableKey: 'pk_test_mock', - secretKey: 'sk_test_mock', - }); - }); - - it('allows overriding options', async () => { - mockBackendProxy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); - - const request = new Request('https://example.com/custom/__clerk/v1/client'); - await clerkFrontendApiProxy(request, { - proxyPath: '/custom/__clerk', - publishableKey: 'pk_custom', - secretKey: 'sk_custom', - }); - - expect(mockBackendProxy).toHaveBeenCalledWith(request, { - proxyPath: '/custom/__clerk', - publishableKey: 'pk_custom', - secretKey: 'sk_custom', - }); - }); - - it('returns response from backend proxy', async () => { - const expectedResponse = new Response(JSON.stringify({ client: {} }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - mockBackendProxy.mockResolvedValue(expectedResponse); - - const request = new Request('https://example.com/__clerk/v1/client'); - const response = await clerkFrontendApiProxy(request); - - expect(response).toBe(expectedResponse); - }); - }); - - describe('exports', () => { - it('exports all required functions and constants', async () => { - const proxyModule = await import('../proxy.js'); - - expect(proxyModule).toHaveProperty('clerkFrontendApiProxy'); - expect(proxyModule).toHaveProperty('createFrontendApiProxyHandlers'); - expect(proxyModule).toHaveProperty('DEFAULT_PROXY_PATH'); - }); - }); -}); From dc1349e3b39a13ed6c60611e2abd71f34a5cd511 Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 16 Jan 2026 14:22:55 -0600 Subject: [PATCH 04/10] feat: Add multi-domain support for Frontend API proxy Allow the `enabled` option in `frontendApiProxy` to accept a function `(url: URL) => boolean` for conditional proxy based on the request URL. This enables scenarios where an application has multiple domains and only some require proxying (e.g., `foo.replit.app` proxied while `foo.com` uses direct FAPI access). Co-Authored-By: Claude Opus 4.5 --- .../server/__tests__/clerkMiddleware.test.ts | 164 ++++++++++++++++++ packages/nextjs/src/server/clerkMiddleware.ts | 12 +- packages/nextjs/src/server/types.ts | 9 +- packages/shared/src/proxy.ts | 5 + 4 files changed, 185 insertions(+), 5 deletions(-) diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 69419e2d504..1a26de0e664 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -937,3 +937,167 @@ 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(); + }); +}); diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index ca70a6e2f6f..8b3b01c21d3 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -158,9 +158,15 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl ); // Handle Frontend API proxy requests early, before authentication - if (resolvedParams.frontendApiProxy?.enabled) { - const proxyPath = resolvedParams.frontendApiProxy.path || DEFAULT_PROXY_PATH; - if (matchProxyPath(request, { proxyPath })) { + 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, diff --git a/packages/nextjs/src/server/types.ts b/packages/nextjs/src/server/types.ts index a7d8b1a9258..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 }; @@ -17,9 +19,12 @@ export type NextMiddlewareReturn = ReturnType; */ export interface FrontendApiProxyOptions { /** - * Enable proxy handling in middleware + * 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; + enabled: boolean | ShouldProxyFn; /** * The path prefix for proxy requests. Defaults to `/__clerk`. */ 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; From 93feba58d154a1ebb8e8cdb2616a8b430284139c Mon Sep 17 00:00:00 2001 From: brkalow Date: Wed, 21 Jan 2026 14:09:08 -0600 Subject: [PATCH 05/10] fix: Improve proxy header handling and redirect rewriting - Add X-Forwarded-Host and X-Forwarded-Proto headers for proxy awareness - Preserve existing X-Forwarded-* headers from upstream proxies - Rewrite Location headers for FAPI redirects to go through the proxy - Add tests for new header handling and redirect rewriting Co-Authored-By: Claude Opus 4.5 --- packages/backend/src/__tests__/proxy.test.ts | 100 +++++++++++++++++++ packages/backend/src/proxy.ts | 32 +++++- 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/__tests__/proxy.test.ts b/packages/backend/src/__tests__/proxy.test.ts index f537b02c749..bdc24987a90 100644 --- a/packages/backend/src/__tests__/proxy.test.ts +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -319,5 +319,105 @@ describe('proxy', () => { 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 index 74d19dc9462..b08b245fba5 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -201,9 +201,21 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend headers.set('Clerk-Secret-Key', secretKey); // Set the host header to the FAPI host - headers.set('Host', new URL(fapiBaseUrl).host); + const fapiHost = new URL(fapiBaseUrl).host; + headers.set('Host', fapiHost); - // Forward client IP + // 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); @@ -236,6 +248,22 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend } }); + // 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, From 307789fa3fe3378d79b5d2972c8d0f47b62fb615 Mon Sep 17 00:00:00 2001 From: brkalow Date: Thu, 22 Jan 2026 10:00:39 -0600 Subject: [PATCH 06/10] feat: Embed Frontend API proxy in clerkMiddleware for Express and Next.js - Remove separate @clerk/express/proxy entry point and middleware - Embed proxy handling directly in Express clerkMiddleware - Auto-derive proxyUrl from frontendApiProxy config for handshake redirects - Add FrontendApiProxyOptions type with enabled and path options - Align API structure between Express and Next.js SDKs - Remove low-value constant tests Co-Authored-By: Claude Opus 4.5 --- packages/backend/src/__tests__/proxy.test.ts | 8 +- packages/express/package.json | 10 - .../__snapshots__/exports.test.ts.snap | 12 - .../src/__tests__/clerkMiddleware.test.ts | 50 ++- packages/express/src/__tests__/helpers.ts | 12 + packages/express/src/__tests__/proxy.test.ts | 299 ------------------ packages/express/src/authenticateRequest.ts | 69 +++- packages/express/src/proxy.ts | 99 ------ packages/express/src/types.ts | 37 +++ packages/express/tsup.config.ts | 2 +- .../server/__tests__/clerkMiddleware.test.ts | 63 ++++ packages/nextjs/src/server/clerkMiddleware.ts | 17 +- 12 files changed, 245 insertions(+), 433 deletions(-) delete mode 100644 packages/express/src/__tests__/proxy.test.ts delete mode 100644 packages/express/src/proxy.ts diff --git a/packages/backend/src/__tests__/proxy.test.ts b/packages/backend/src/__tests__/proxy.test.ts index bdc24987a90..4ea016dee5c 100644 --- a/packages/backend/src/__tests__/proxy.test.ts +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -1,14 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, fapiUrlFromPublishableKey, matchProxyPath } from '../proxy'; +import { clerkFrontendApiProxy, fapiUrlFromPublishableKey, matchProxyPath } from '../proxy'; describe('proxy', () => { - describe('DEFAULT_PROXY_PATH', () => { - it('should be /__clerk', () => { - expect(DEFAULT_PROXY_PATH).toBe('/__clerk'); - }); - }); - describe('fapiUrlFromPublishableKey', () => { it('returns production FAPI URL for production publishable keys', () => { const pk = 'pk_live_Y2xlcmsuZXhhbXBsZS5jb20k'; // clerk.example.com diff --git a/packages/express/package.json b/packages/express/package.json index 70f870a2bbd..8bb09077949 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -43,16 +43,6 @@ "default": "./dist/webhooks.js" } }, - "./proxy": { - "import": { - "types": "./dist/proxy.d.mts", - "default": "./dist/proxy.mjs" - }, - "require": { - "types": "./dist/proxy.d.ts", - "default": "./dist/proxy.js" - } - }, "./env": "./env.d.ts", "./package.json": "./package.json" }, 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..6b677da9b21 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,54 @@ 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: { 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: {} }), '/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/__tests__/proxy.test.ts b/packages/express/src/__tests__/proxy.test.ts deleted file mode 100644 index c4d3750fb64..00000000000 --- a/packages/express/src/__tests__/proxy.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import type { Application } from 'express'; -import express from 'express'; -import { Readable } from 'stream'; -import supertest from 'supertest'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { clerkFrontendApiProxyMiddleware, DEFAULT_PROXY_PATH } from '../proxy'; -import { requestToProxyRequest } from '../utils'; - -// Mock the backend proxy module -vi.mock('@clerk/backend/proxy', () => ({ - clerkFrontendApiProxy: vi.fn(), - DEFAULT_PROXY_PATH: '/__clerk', -})); - -describe('proxy', () => { - describe('DEFAULT_PROXY_PATH', () => { - it('should be /__clerk', () => { - expect(DEFAULT_PROXY_PATH).toBe('/__clerk'); - }); - }); - - describe('clerkFrontendApiProxyMiddleware', () => { - let mockProxy: ReturnType; - - beforeEach(async () => { - const { clerkFrontendApiProxy } = await import('@clerk/backend/proxy'); - mockProxy = vi.mocked(clerkFrontendApiProxy); - mockProxy.mockReset(); - - // Set up environment variables - process.env.CLERK_PUBLISHABLE_KEY = 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k'; - process.env.CLERK_SECRET_KEY = 'sk_test_xxx'; - }); - - afterEach(() => { - delete process.env.CLERK_PUBLISHABLE_KEY; - delete process.env.CLERK_SECRET_KEY; - }); - - function createApp() { - const app: Application = express(); - app.use('/__clerk', clerkFrontendApiProxyMiddleware()); - return app; - } - - it('returns middleware function', () => { - const middleware = clerkFrontendApiProxyMiddleware(); - expect(typeof middleware).toBe('function'); - }); - - it('proxies GET requests', async () => { - mockProxy.mockResolvedValue( - new Response(JSON.stringify({ client: {} }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - - const app = createApp(); - const response = await supertest(app).get('/__clerk/v1/client'); - - expect(mockProxy).toHaveBeenCalledTimes(1); - expect(response.status).toBe(200); - expect(response.body).toEqual({ client: {} }); - }); - - it('proxies POST requests', async () => { - mockProxy.mockResolvedValue( - new Response(JSON.stringify({ success: true }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - - const app = createApp(); - const response = await supertest(app).post('/__clerk/v1/sign_ups').send({ email: 'test@example.com' }); - - expect(mockProxy).toHaveBeenCalledTimes(1); - expect(response.status).toBe(200); - }); - - it('passes through error status codes', async () => { - mockProxy.mockResolvedValue( - new Response(JSON.stringify({ errors: [{ message: 'Unauthorized' }] }), { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }), - ); - - const app = createApp(); - const response = await supertest(app).get('/__clerk/v1/client'); - - expect(response.status).toBe(401); - }); - - it('uses baseUrl as proxyPath', async () => { - mockProxy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); - - const app: Application = express(); - app.use('/custom-proxy', clerkFrontendApiProxyMiddleware()); - - await supertest(app).get('/custom-proxy/v1/client'); - - expect(mockProxy).toHaveBeenCalledWith( - expect.any(Request), - expect.objectContaining({ - proxyPath: '/custom-proxy', - }), - ); - }); - - it('uses custom publishableKey and secretKey when provided', async () => { - mockProxy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); - - const app: Application = express(); - app.use( - '/__clerk', - clerkFrontendApiProxyMiddleware({ - publishableKey: 'pk_custom', - secretKey: 'sk_custom', - }), - ); - - await supertest(app).get('/__clerk/v1/client'); - - expect(mockProxy).toHaveBeenCalledWith( - expect.any(Request), - expect.objectContaining({ - publishableKey: 'pk_custom', - secretKey: 'sk_custom', - }), - ); - }); - - it('forwards response headers', async () => { - mockProxy.mockResolvedValue( - new Response(JSON.stringify({}), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'X-Custom-Header': 'custom-value', - }, - }), - ); - - const app = createApp(); - const response = await supertest(app).get('/__clerk/v1/client'); - - expect(response.headers['content-type']).toContain('application/json'); - expect(response.headers['x-custom-header']).toBe('custom-value'); - }); - }); - - describe('requestToProxyRequest', () => { - it('converts Express request to Fetch Request', () => { - const mockExpressReq = { - method: 'GET', - protocol: 'https', - secure: true, - originalUrl: '/__clerk/v1/client', - url: '/__clerk/v1/client', - headers: { - host: 'example.com', - 'user-agent': 'Test Agent', - }, - get: (header: string) => { - const headers: Record = { - host: 'example.com', - 'user-agent': 'Test Agent', - }; - return headers[header.toLowerCase()]; - }, - } as any; - - const request = requestToProxyRequest(mockExpressReq); - - expect(request.method).toBe('GET'); - expect(request.url).toBe('https://example.com/__clerk/v1/client'); - expect(request.headers.get('host')).toBe('example.com'); - expect(request.headers.get('user-agent')).toBe('Test Agent'); - }); - - it('includes body for POST requests', () => { - // Create a real Readable stream with Express-like properties - const mockStream = new Readable({ - read() { - this.push(JSON.stringify({ email: 'test@example.com' })); - this.push(null); - }, - }); - - // Add Express-specific properties to the stream - const mockExpressReq = Object.assign(mockStream, { - method: 'POST', - protocol: 'https', - secure: true, - originalUrl: '/__clerk/v1/sign_ups', - url: '/__clerk/v1/sign_ups', - headers: { - host: 'example.com', - 'content-type': 'application/json', - }, - get: (header: string) => { - const headers: Record = { - host: 'example.com', - 'content-type': 'application/json', - }; - return headers[header.toLowerCase()]; - }, - }) as any; - - const request = requestToProxyRequest(mockExpressReq); - - expect(request.method).toBe('POST'); - // Body should be defined for POST - expect(request.body).toBeDefined(); - }); - - it('does not include body for GET requests', () => { - const mockExpressReq = { - method: 'GET', - protocol: 'https', - secure: true, - originalUrl: '/__clerk/v1/client', - url: '/__clerk/v1/client', - headers: { - host: 'example.com', - }, - get: (header: string) => { - const headers: Record = { - host: 'example.com', - }; - return headers[header.toLowerCase()]; - }, - } as any; - - const request = requestToProxyRequest(mockExpressReq); - - expect(request.body).toBeNull(); - }); - - it('handles array header values', () => { - const mockExpressReq = { - method: 'GET', - protocol: 'https', - secure: true, - originalUrl: '/__clerk/v1/client', - url: '/__clerk/v1/client', - headers: { - host: 'example.com', - 'accept-encoding': ['gzip', 'deflate'], - }, - get: (header: string) => { - const headers: Record = { - host: 'example.com', - }; - return headers[header.toLowerCase()]; - }, - } as any; - - const request = requestToProxyRequest(mockExpressReq); - - expect(request.headers.get('accept-encoding')).toBe('gzip, deflate'); - }); - - it('uses http protocol when not secure', () => { - const mockExpressReq = { - method: 'GET', - protocol: 'http', - secure: false, - originalUrl: '/__clerk/v1/client', - url: '/__clerk/v1/client', - headers: { - host: 'localhost:3000', - }, - get: (header: string) => { - const headers: Record = { - host: 'localhost:3000', - }; - return headers[header.toLowerCase()]; - }, - } as any; - - const request = requestToProxyRequest(mockExpressReq); - - expect(request.url).toBe('http://localhost:3000/__clerk/v1/client'); - }); - }); - - describe('exports', () => { - it('exports all required functions and constants', async () => { - const proxyModule = await import('../proxy'); - - expect(proxyModule).toHaveProperty('clerkFrontendApiProxyMiddleware'); - expect(proxyModule).toHaveProperty('DEFAULT_PROXY_PATH'); - }); - }); -}); diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts index 4788760ae98..ba679941ac8 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 with defaults + const frontendApiProxy = options.frontendApiProxy; + const proxyEnabled = frontendApiProxy?.enabled ?? true; + const proxyPath = frontendApiProxy?.path ?? DEFAULT_PROXY_PATH; + // 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 (frontendApiProxy && proxyEnabled) { + const requestPath = request.originalUrl || request.url; + if (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 (frontendApiProxy && 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/proxy.ts b/packages/express/src/proxy.ts deleted file mode 100644 index 5bdc14771b9..00000000000 --- a/packages/express/src/proxy.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Readable } from 'stream'; - -import { clerkFrontendApiProxy, type FrontendApiProxyOptions } from '@clerk/backend/proxy'; -import type { NextFunction, Request as ExpressRequest, Response as ExpressResponse } from 'express'; - -import { loadApiEnv, loadClientEnv, requestToProxyRequest } from './utils'; - -export { DEFAULT_PROXY_PATH, type FrontendApiProxyOptions } from '@clerk/backend/proxy'; - -/** - * Options for the Express Frontend API proxy middleware - */ -export interface ExpressFrontendApiProxyOptions extends Omit {} - -/** - * Creates Express middleware that proxies requests to Clerk's Frontend API. - * - * This middleware 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 options - Proxy configuration options - * @returns Express middleware handler - * - * @example - * ```typescript - * import express from 'express'; - * import { clerkFrontendApiProxyMiddleware } from '@clerk/express/proxy'; - * - * const app = express(); - * - * // Mount the proxy middleware at /__clerk - * app.use('/__clerk', clerkFrontendApiProxyMiddleware()); - * - * app.listen(3000); - * ``` - * - * @example - * ```typescript - * // With custom options - * app.use('/__clerk', clerkFrontendApiProxyMiddleware({ - * publishableKey: 'pk_...', - * secretKey: 'sk_...', - * })); - * ``` - */ -export function clerkFrontendApiProxyMiddleware(options?: ExpressFrontendApiProxyOptions) { - return async (req: ExpressRequest, res: ExpressResponse, _next: NextFunction) => { - const clientEnv = loadClientEnv(); - const apiEnv = loadApiEnv(); - - const publishableKey = options?.publishableKey || clientEnv.publishableKey; - const secretKey = options?.secretKey || apiEnv.secretKey; - - // Convert Express request to Fetch API Request with body streaming - const request = requestToProxyRequest(req); - - // The proxy path is determined by where this middleware is mounted in Express - // We extract it from the request URL by looking at the baseUrl - const proxyPath = req.baseUrl || '/__clerk'; - - // Call the core proxy function - const response = await clerkFrontendApiProxy(request, { - proxyPath, - publishableKey, - secretKey, - }); - - // Set response status - res.status(response.status); - - // Copy response headers - response.headers.forEach((value, key) => { - res.setHeader(key, value); - }); - - // Stream the response body - if (response.body) { - const reader = response.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(res); - } else { - res.end(); - } - }; -} diff --git a/packages/express/src/types.ts b/packages/express/src/types.ts index ac9f074cb4e..a7c0780bc79 100644 --- a/packages/express/src/types.ts +++ b/packages/express/src/types.ts @@ -7,6 +7,25 @@ export type ExpressRequestWithAuth = ExpressRequest & { auth: (options?: PendingSessionOptions) => SignedInAuthObject | SignedOutAuthObject; }; +/** + * Options for configuring Frontend API proxy in clerkMiddleware + */ +export interface FrontendApiProxyOptions { + /** + * Enable proxy path skipping. When true, requests to the proxy path will + * bypass authentication to avoid redirect loops. + * + * @default true + */ + enabled?: boolean; + /** + * The path prefix for proxy requests. + * + * @default '/__clerk' + */ + path?: string; +} + export type ClerkMiddlewareOptions = AuthenticateRequestOptions & { debug?: boolean; clerkClient?: ClerkClient; @@ -18,6 +37,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 + * // Use defaults (path: '/__clerk', enabled: true) + * clerkMiddleware({ frontendApiProxy: {} }) + * + * @example + * // Custom path + * clerkMiddleware({ frontendApiProxy: { path: '/my-proxy' } }) + * + * @example + * // Disable proxy handling + * clerkMiddleware({ frontendApiProxy: { enabled: false } }) + */ + frontendApiProxy?: FrontendApiProxyOptions; }; type ClerkClient = ReturnType; diff --git a/packages/express/tsup.config.ts b/packages/express/tsup.config.ts index 40db93fa587..be41714b765 100644 --- a/packages/express/tsup.config.ts +++ b/packages/express/tsup.config.ts @@ -6,7 +6,7 @@ export default defineConfig(overrideOptions => { const isWatch = !!overrideOptions.watch; return { - entry: ['./src/index.ts', './src/webhooks.ts', './src/proxy.ts'], + entry: ['./src/index.ts', './src/webhooks.ts'], format: ['cjs', 'esm'], bundle: true, clean: true, diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 1a26de0e664..56047c5eaaa 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1100,4 +1100,67 @@ describe('frontendApiProxy multi-domain support', () => { 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 8b3b01c21d3..c0840f744d3 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -393,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. From 77f9869c34df2c783d3d4fe53be607357bcd0c79 Mon Sep 17 00:00:00 2001 From: brkalow Date: Thu, 22 Jan 2026 13:40:06 -0600 Subject: [PATCH 07/10] docs: Update Express frontendApiProxy examples to use enabled: true Co-Authored-By: Claude Opus 4.5 --- packages/express/src/types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/express/src/types.ts b/packages/express/src/types.ts index a7c0780bc79..ec074b85248 100644 --- a/packages/express/src/types.ts +++ b/packages/express/src/types.ts @@ -43,12 +43,12 @@ export type ClerkMiddlewareOptions = AuthenticateRequestOptions & { * for handshake redirects. * * @example - * // Use defaults (path: '/__clerk', enabled: true) - * clerkMiddleware({ frontendApiProxy: {} }) + * // Enable with defaults (path: '/__clerk') + * clerkMiddleware({ frontendApiProxy: { enabled: true } }) * * @example * // Custom path - * clerkMiddleware({ frontendApiProxy: { path: '/my-proxy' } }) + * clerkMiddleware({ frontendApiProxy: { enabled: true, path: '/my-proxy' } }) * * @example * // Disable proxy handling From 1c941f815bf4c3e323387f761bef9c391c38a5d5 Mon Sep 17 00:00:00 2001 From: brkalow Date: Thu, 22 Jan 2026 13:43:02 -0600 Subject: [PATCH 08/10] fix: Require explicit enabled: true for Express frontendApiProxy - Change from defaulting enabled to true, to requiring explicit enabled: true - Update tests to use enabled: true - Update JSDoc to remove default annotation Co-Authored-By: Claude Opus 4.5 --- .../express/src/__tests__/clerkMiddleware.test.ts | 14 +++++++++----- packages/express/src/authenticateRequest.ts | 4 ++-- packages/express/src/types.ts | 6 ++---- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/express/src/__tests__/clerkMiddleware.test.ts b/packages/express/src/__tests__/clerkMiddleware.test.ts index 6b677da9b21..572169cdd23 100644 --- a/packages/express/src/__tests__/clerkMiddleware.test.ts +++ b/packages/express/src/__tests__/clerkMiddleware.test.ts @@ -119,7 +119,7 @@ describe('clerkMiddleware', () => { 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: { path: '/custom-clerk-proxy' } }), + clerkMiddleware({ frontendApiProxy: { enabled: true, path: '/custom-clerk-proxy' } }), '/__clerk/v1/client', { Cookie: '__client_uat=1711618859;', @@ -154,10 +154,14 @@ describe('clerkMiddleware', () => { }); it('still authenticates requests to other paths when proxy is configured', async () => { - const response = await runMiddlewareOnPath(clerkMiddleware({ frontendApiProxy: {} }), '/api/users', { - Cookie: '__client_uat=1711618859;', - 'Sec-Fetch-Dest': 'document', - }).expect(307); + 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'); }); diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts index ba679941ac8..e64024c8722 100644 --- a/packages/express/src/authenticateRequest.ts +++ b/packages/express/src/authenticateRequest.ts @@ -104,9 +104,9 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = const clerkClient = options.clerkClient || defaultClerkClient; const enableHandshake = options.enableHandshake ?? true; - // Extract proxy configuration with defaults + // Extract proxy configuration const frontendApiProxy = options.frontendApiProxy; - const proxyEnabled = frontendApiProxy?.enabled ?? true; + const proxyEnabled = frontendApiProxy?.enabled === true; const proxyPath = frontendApiProxy?.path ?? DEFAULT_PROXY_PATH; // eslint-disable-next-line @typescript-eslint/no-misused-promises diff --git a/packages/express/src/types.ts b/packages/express/src/types.ts index ec074b85248..d0f7ba79ffb 100644 --- a/packages/express/src/types.ts +++ b/packages/express/src/types.ts @@ -12,10 +12,8 @@ export type ExpressRequestWithAuth = ExpressRequest & { */ export interface FrontendApiProxyOptions { /** - * Enable proxy path skipping. When true, requests to the proxy path will - * bypass authentication to avoid redirect loops. - * - * @default true + * 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; /** From 427dad738566673126d0b26cf4bff1cd0744f2ea Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 23 Jan 2026 12:43:24 -0600 Subject: [PATCH 09/10] fix: Use path boundary check for proxy path matching Fixes issue where paths like /__clerk-admin would incorrectly match the /__clerk proxy path. Now requires either an exact match or a trailing slash boundary. Co-Authored-By: Claude Opus 4.5 --- packages/backend/src/__tests__/proxy.test.ts | 21 ++++++++++++++++++++ packages/backend/src/proxy.ts | 5 +++-- packages/express/src/authenticateRequest.ts | 6 +++--- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/__tests__/proxy.test.ts b/packages/backend/src/__tests__/proxy.test.ts index 4ea016dee5c..8ff704ee478 100644 --- a/packages/backend/src/__tests__/proxy.test.ts +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -66,6 +66,12 @@ describe('proxy', () => { 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); + }); }); describe('clerkFrontendApiProxy', () => { @@ -121,6 +127,21 @@ describe('proxy', () => { 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, diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts index b08b245fba5..e89f2c3258d 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -84,7 +84,7 @@ export function fapiUrlFromPublishableKey(publishableKey: string): string { export function matchProxyPath(request: Request, options?: Pick): boolean { const proxyPath = options?.proxyPath || DEFAULT_PROXY_PATH; const url = new URL(request.url); - return url.pathname.startsWith(proxyPath); + return url.pathname === proxyPath || url.pathname.startsWith(proxyPath + '/'); } /** @@ -171,7 +171,8 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend // Get the request URL and validate path const requestUrl = new URL(request.url); - if (!requestUrl.pathname.startsWith(proxyPath)) { + 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}"`, diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts index e64024c8722..60230c8b2ef 100644 --- a/packages/express/src/authenticateRequest.ts +++ b/packages/express/src/authenticateRequest.ts @@ -120,9 +120,9 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = const secretKey = options.secretKey || env.secretKey; // Handle Frontend API proxy requests early, before authentication - if (frontendApiProxy && proxyEnabled) { + if (proxyEnabled) { const requestPath = request.originalUrl || request.url; - if (requestPath.startsWith(proxyPath)) { + if (requestPath === proxyPath || requestPath.startsWith(proxyPath + '/')) { // Convert Express request to Fetch API Request const proxyRequest = requestToProxyRequest(request); @@ -165,7 +165,7 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = // Auto-derive proxyUrl from frontendApiProxy config if not explicitly set let resolvedOptions = options; - if (frontendApiProxy && proxyEnabled && !options.proxyUrl) { + 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}`; From fb6b5365e2425e0d2179d40000a7ee3bed73aed1 Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 23 Jan 2026 12:45:25 -0600 Subject: [PATCH 10/10] fix: Normalize proxy path by removing trailing slashes Ensures that proxy paths like /__clerk/ work correctly by stripping trailing slashes before matching. Co-Authored-By: Claude Opus 4.5 --- packages/backend/src/__tests__/proxy.test.ts | 6 ++++++ packages/backend/src/proxy.ts | 4 ++-- packages/express/src/authenticateRequest.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/__tests__/proxy.test.ts b/packages/backend/src/__tests__/proxy.test.ts index 8ff704ee478..ef76a722161 100644 --- a/packages/backend/src/__tests__/proxy.test.ts +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -72,6 +72,12 @@ describe('proxy', () => { 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', () => { diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts index e89f2c3258d..8408b96d79f 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -82,7 +82,7 @@ export function fapiUrlFromPublishableKey(publishableKey: string): string { * @returns True if the request matches the proxy path */ export function matchProxyPath(request: Request, options?: Pick): boolean { - const proxyPath = options?.proxyPath || DEFAULT_PROXY_PATH; + const proxyPath = (options?.proxyPath || DEFAULT_PROXY_PATH).replace(/\/+$/, ''); const url = new URL(request.url); return url.pathname === proxyPath || url.pathname.startsWith(proxyPath + '/'); } @@ -147,7 +147,7 @@ function getClientIp(request: Request): string | undefined { * ``` */ export async function clerkFrontendApiProxy(request: Request, options?: FrontendApiProxyOptions): Promise { - const proxyPath = options?.proxyPath || DEFAULT_PROXY_PATH; + 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); diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts index 60230c8b2ef..1706c147481 100644 --- a/packages/express/src/authenticateRequest.ts +++ b/packages/express/src/authenticateRequest.ts @@ -107,7 +107,7 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = // Extract proxy configuration const frontendApiProxy = options.frontendApiProxy; const proxyEnabled = frontendApiProxy?.enabled === true; - const proxyPath = frontendApiProxy?.path ?? DEFAULT_PROXY_PATH; + const proxyPath = (frontendApiProxy?.path ?? DEFAULT_PROXY_PATH).replace(/\/+$/, ''); // eslint-disable-next-line @typescript-eslint/no-misused-promises const middleware: RequestHandler = async (request, response, next) => {