1 line
17 KiB
Plaintext
1 line
17 KiB
Plaintext
{"version":3,"sources":["../src/proxy.ts"],"sourcesContent":["import {\n DEFAULT_PROXY_PATH,\n LEGACY_DEV_INSTANCE_SUFFIXES,\n LOCAL_ENV_SUFFIXES,\n LOCAL_FAPI_URL,\n PROD_FAPI_URL,\n STAGING_ENV_SUFFIXES,\n STAGING_FAPI_URL,\n} from '@clerk/shared/constants';\nimport { parsePublishableKey } from '@clerk/shared/keys';\n\nexport { DEFAULT_PROXY_PATH } from '@clerk/shared/constants';\n\n/**\n * Options for the Frontend API proxy\n */\nexport interface FrontendApiProxyOptions {\n /**\n * The path prefix for proxy requests. Defaults to `/__clerk`.\n */\n proxyPath?: string;\n /**\n * The Clerk publishable key. Falls back to CLERK_PUBLISHABLE_KEY env var.\n */\n publishableKey?: string;\n /**\n * The Clerk secret key. Falls back to CLERK_SECRET_KEY env var.\n */\n secretKey?: string;\n}\n\n/**\n * Error codes for proxy errors\n */\nexport type ProxyErrorCode = 'proxy_configuration_error' | 'proxy_path_mismatch' | 'proxy_request_failed';\n\n/**\n * Error response structure for proxy errors\n */\nexport interface ProxyError {\n code: ProxyErrorCode;\n message: string;\n}\n\n// Hop-by-hop headers that should not be forwarded\nconst HOP_BY_HOP_HEADERS = new Set([\n 'connection',\n 'keep-alive',\n 'proxy-authenticate',\n 'proxy-authorization',\n 'te',\n 'trailer',\n 'transfer-encoding',\n 'upgrade',\n]);\n\n/**\n * Parses the Connection header to extract dynamically-nominated hop-by-hop\n * header names (RFC 7230 Section 6.1). These headers are specific to the\n * current connection and must not be forwarded by proxies.\n */\nfunction getDynamicHopByHopHeaders(headers: Headers): Set<string> {\n const connectionValue = headers.get('connection');\n if (!connectionValue) {\n return new Set();\n }\n return new Set(\n connectionValue\n .split(',')\n .map(h => h.trim().toLowerCase())\n .filter(h => h.length > 0),\n );\n}\n\n// Headers to strip from proxied responses. fetch() auto-decompresses\n// response bodies, so Content-Encoding no longer describes the body\n// and Content-Length reflects the compressed size. We request identity\n// encoding upstream to avoid the double compression pass, but strip\n// these defensively since servers may ignore Accept-Encoding: identity.\nconst RESPONSE_HEADERS_TO_STRIP = new Set(['content-encoding', 'content-length']);\n\n/**\n * Derives the Frontend API URL from a publishable key.\n * @param publishableKey - The Clerk publishable key\n * @returns The Frontend API URL for the environment\n */\nexport function fapiUrlFromPublishableKey(publishableKey: string): string {\n const frontendApi = parsePublishableKey(publishableKey)?.frontendApi;\n\n if (frontendApi?.startsWith('clerk.') && LEGACY_DEV_INSTANCE_SUFFIXES.some(suffix => frontendApi?.endsWith(suffix))) {\n return PROD_FAPI_URL;\n }\n\n if (LOCAL_ENV_SUFFIXES.some(suffix => frontendApi?.endsWith(suffix))) {\n return LOCAL_FAPI_URL;\n }\n if (STAGING_ENV_SUFFIXES.some(suffix => frontendApi?.endsWith(suffix))) {\n return STAGING_FAPI_URL;\n }\n return PROD_FAPI_URL;\n}\n\n/**\n * Removes trailing slashes from a string without using regex\n * to avoid potential ReDoS concerns flagged by security scanners.\n */\nexport function stripTrailingSlashes(str: string): string {\n while (str.endsWith('/')) {\n str = str.slice(0, -1);\n }\n return str;\n}\n\n/**\n * Checks if a request path matches the proxy path.\n * @param request - The incoming request\n * @param options - Proxy options including the proxy path\n * @returns True if the request matches the proxy path\n */\nexport function matchProxyPath(request: Request, options?: Pick<FrontendApiProxyOptions, 'proxyPath'>): boolean {\n const proxyPath = stripTrailingSlashes(options?.proxyPath || DEFAULT_PROXY_PATH);\n const url = new URL(request.url);\n return url.pathname === proxyPath || url.pathname.startsWith(proxyPath + '/');\n}\n\n/**\n * Creates a JSON error response\n */\nfunction createErrorResponse(code: ProxyErrorCode, message: string, status: number): Response {\n const error: ProxyError = { code, message };\n return new Response(JSON.stringify({ errors: [error] }), {\n status,\n headers: {\n 'Content-Type': 'application/json',\n 'Cache-Control': 'no-store',\n },\n });\n}\n\n/**\n * Derives the public-facing origin from forwarded headers, falling back to the raw request URL.\n * Behind a reverse proxy, request.url is typically localhost, but the Clerk-Proxy-Url header\n * and Location rewrites must use the origin visible to the browser.\n */\nfunction derivePublicOrigin(request: Request, requestUrl: URL): string {\n const forwardedProto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim();\n const forwardedHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim();\n\n if (forwardedProto && forwardedHost) {\n return `${forwardedProto}://${forwardedHost}`;\n }\n\n return requestUrl.origin;\n}\n\n/**\n * Gets the client IP address from various headers\n */\nfunction getClientIp(request: Request): string | undefined {\n const cfConnectingIp = request.headers.get('cf-connecting-ip');\n if (cfConnectingIp) {\n return cfConnectingIp;\n }\n\n const xRealIp = request.headers.get('x-real-ip');\n if (xRealIp) {\n return xRealIp;\n }\n\n const xForwardedFor = request.headers.get('x-forwarded-for');\n if (xForwardedFor) {\n // Take the first IP in the chain\n return xForwardedFor.split(',')[0]?.trim();\n }\n\n return undefined;\n}\n\n/**\n * Proxies a request to Clerk's Frontend API.\n *\n * This function handles forwarding requests from your application to Clerk's\n * Frontend API, enabling scenarios where direct communication with Clerk's API\n * is blocked or needs to go through your application server.\n *\n * @param request - The incoming request to proxy\n * @param options - Proxy configuration options\n * @returns A Response from Clerk's Frontend API\n *\n * @example\n * ```typescript\n * import { clerkFrontendApiProxy } from '@clerk/backend/proxy';\n *\n * // In a route handler\n * const response = await clerkFrontendApiProxy(request, {\n * proxyPath: '/__clerk',\n * publishableKey: process.env.CLERK_PUBLISHABLE_KEY,\n * secretKey: process.env.CLERK_SECRET_KEY,\n * });\n * ```\n */\nexport async function clerkFrontendApiProxy(request: Request, options?: FrontendApiProxyOptions): Promise<Response> {\n const proxyPath = stripTrailingSlashes(options?.proxyPath || DEFAULT_PROXY_PATH);\n const publishableKey =\n options?.publishableKey || (typeof process !== 'undefined' ? process.env?.CLERK_PUBLISHABLE_KEY : undefined);\n const secretKey = options?.secretKey || (typeof process !== 'undefined' ? process.env?.CLERK_SECRET_KEY : undefined);\n\n // Validate configuration\n if (!publishableKey) {\n return createErrorResponse(\n 'proxy_configuration_error',\n 'Missing publishableKey. Provide it in options or set CLERK_PUBLISHABLE_KEY environment variable.',\n 500,\n );\n }\n\n if (!secretKey) {\n return createErrorResponse(\n 'proxy_configuration_error',\n 'Missing secretKey. Provide it in options or set CLERK_SECRET_KEY environment variable.',\n 500,\n );\n }\n\n // Get the request URL and validate path\n const requestUrl = new URL(request.url);\n const pathMatches = requestUrl.pathname === proxyPath || requestUrl.pathname.startsWith(proxyPath + '/');\n if (!pathMatches) {\n return createErrorResponse(\n 'proxy_path_mismatch',\n `Request path \"${requestUrl.pathname}\" does not match proxy path \"${proxyPath}\"`,\n 400,\n );\n }\n\n // Derive the FAPI URL and construct the target URL.\n // Use string concatenation instead of `new URL(path, base)` to avoid\n // protocol-relative resolution (e.g., \"//evil.com\" resolving to a different host).\n const fapiBaseUrl = fapiUrlFromPublishableKey(publishableKey);\n const fapiHost = new URL(fapiBaseUrl).host;\n const targetPath = requestUrl.pathname.slice(proxyPath.length) || '/';\n const targetUrl = new URL(`${fapiBaseUrl}${targetPath}`);\n targetUrl.search = requestUrl.search;\n\n if (targetUrl.host !== fapiHost) {\n return createErrorResponse('proxy_request_failed', 'Resolved target does not match the expected host', 400);\n }\n\n // Build headers for the proxied request\n const headers = new Headers();\n\n // Copy original headers, excluding hop-by-hop headers and any\n // dynamically-nominated hop-by-hop headers listed in the Connection header (RFC 7230 Section 6.1).\n const dynamicHopByHop = getDynamicHopByHopHeaders(request.headers);\n request.headers.forEach((value, key) => {\n const lower = key.toLowerCase();\n if (!HOP_BY_HOP_HEADERS.has(lower) && !dynamicHopByHop.has(lower)) {\n headers.set(key, value);\n }\n });\n\n // Set required Clerk proxy headers\n // Use the public origin (from forwarded headers) so the Clerk-Proxy-Url\n // points to the browser-visible host, not localhost behind a reverse proxy.\n const publicOrigin = derivePublicOrigin(request, requestUrl);\n const proxyUrl = `${publicOrigin}${proxyPath}`;\n headers.set('Clerk-Proxy-Url', proxyUrl);\n headers.set('Clerk-Secret-Key', secretKey);\n\n // Set the host header to the FAPI host\n headers.set('Host', fapiHost);\n\n // Request uncompressed responses to avoid a double compression pass.\n // fetch() auto-decompresses, so without this FAPI compresses → fetch\n // decompresses → the serving layer re-compresses for the browser.\n headers.set('Accept-Encoding', 'identity');\n\n // Set X-Forwarded-* headers for proxy awareness\n // Only set these if not already present (preserve values from upstream proxies)\n if (!headers.has('X-Forwarded-Host')) {\n headers.set('X-Forwarded-Host', requestUrl.host);\n }\n if (!headers.has('X-Forwarded-Proto')) {\n headers.set('X-Forwarded-Proto', requestUrl.protocol.replace(':', ''));\n }\n\n // Set X-Forwarded-For to the client IP\n // In multi-proxy scenarios, we prefer authoritative headers (CF-Connecting-IP, X-Real-IP)\n // over the existing X-Forwarded-For chain, as they provide the true client IP\n const clientIp = getClientIp(request);\n if (clientIp) {\n headers.set('X-Forwarded-For', clientIp);\n }\n\n // Determine if request has a body (handles DELETE-with-body and any other method)\n const hasBody = request.body !== null;\n\n try {\n // Make the proxied request\n // TODO: Consider adding AbortSignal.timeout(30_000) via AbortSignal.any()\n const fetchOptions: RequestInit = {\n method: request.method,\n headers,\n redirect: 'manual',\n signal: request.signal,\n };\n\n // Only set duplex when body is present (required for streaming bodies)\n if (hasBody) {\n // @ts-expect-error - duplex is required for streaming bodies, but not present on the RequestInit type from undici\n fetchOptions.duplex = 'half';\n fetchOptions.body = request.body;\n }\n\n const response = await fetch(targetUrl.toString(), fetchOptions);\n\n // Build response headers, excluding hop-by-hop and encoding headers.\n // Also strip dynamically-nominated hop-by-hop headers from the response Connection header.\n const responseDynamicHopByHop = getDynamicHopByHopHeaders(response.headers);\n const responseHeaders = new Headers();\n response.headers.forEach((value, key) => {\n const lower = key.toLowerCase();\n if (\n !HOP_BY_HOP_HEADERS.has(lower) &&\n !RESPONSE_HEADERS_TO_STRIP.has(lower) &&\n !responseDynamicHopByHop.has(lower)\n ) {\n if (lower === 'set-cookie') {\n responseHeaders.append(key, value);\n } else {\n responseHeaders.set(key, value);\n }\n }\n });\n\n // Rewrite Location header for redirects to go through the proxy\n const locationHeader = response.headers.get('location');\n if (locationHeader) {\n try {\n const locationUrl = new URL(locationHeader, fapiBaseUrl);\n // Check if the redirect points to the FAPI host\n if (locationUrl.host === fapiHost) {\n // Rewrite to go through the proxy\n const rewrittenLocation = `${proxyUrl}${locationUrl.pathname}${locationUrl.search}${locationUrl.hash}`;\n responseHeaders.set('Location', rewrittenLocation);\n }\n } catch {\n // If URL parsing fails, leave the Location header as-is (could be a relative URL)\n }\n }\n\n const proxyResponse = new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers: responseHeaders,\n });\n\n // Some runtimes may re-add Content-Length when constructing the Response.\n // Delete explicitly since fetch() decoded the body and the original values\n // no longer reflect the actual content.\n for (const header of RESPONSE_HEADERS_TO_STRIP) {\n proxyResponse.headers.delete(header);\n }\n\n return proxyResponse;\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown error';\n return createErrorResponse('proxy_request_failed', `Failed to proxy request to Clerk FAPI: ${message}`, 502);\n }\n}\n"],"mappings":"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","names":["DEFAULT_PROXY_PATH"]} |