-
Notifications
You must be signed in to change notification settings - Fork 450
Expand file tree
/
Copy pathproxy.ts
More file actions
370 lines (328 loc) · 12.5 KB
/
proxy.ts
File metadata and controls
370 lines (328 loc) · 12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
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 = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]);
/**
* Parses the Connection header to extract dynamically-nominated hop-by-hop
* header names (RFC 7230 Section 6.1). These headers are specific to the
* current connection and must not be forwarded by proxies.
*/
function getDynamicHopByHopHeaders(headers: Headers): Set<string> {
const connectionValue = headers.get('connection');
if (!connectionValue) {
return new Set();
}
return new Set(
connectionValue
.split(',')
.map(h => h.trim().toLowerCase())
.filter(h => h.length > 0),
);
}
// Headers to strip from proxied responses. fetch() auto-decompresses
// response bodies, so Content-Encoding no longer describes the body
// and Content-Length reflects the compressed size. We request identity
// encoding upstream to avoid the double compression pass, but strip
// these defensively since servers may ignore Accept-Encoding: identity.
const RESPONSE_HEADERS_TO_STRIP = new Set(['content-encoding', 'content-length']);
/**
* 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;
}
/**
* Removes trailing slashes from a string without using regex
* to avoid potential ReDoS concerns flagged by security scanners.
*/
export function stripTrailingSlashes(str: string): string {
while (str.endsWith('/')) {
str = str.slice(0, -1);
}
return str;
}
/**
* 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<FrontendApiProxyOptions, 'proxyPath'>): boolean {
const proxyPath = stripTrailingSlashes(options?.proxyPath || DEFAULT_PROXY_PATH);
const url = new URL(request.url);
return url.pathname === proxyPath || url.pathname.startsWith(proxyPath + '/');
}
/**
* Creates a JSON error response
*/
function createErrorResponse(code: ProxyErrorCode, message: string, status: number): Response {
const error: ProxyError = { code, message };
return new Response(JSON.stringify({ errors: [error] }), {
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
/**
* Derives the public-facing origin from forwarded headers, falling back to the raw request URL.
* Behind a reverse proxy, request.url is typically localhost, but the Clerk-Proxy-Url header
* and Location rewrites must use the origin visible to the browser.
*/
function derivePublicOrigin(request: Request, requestUrl: URL): string {
const forwardedProto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim();
const forwardedHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim();
if (forwardedProto && forwardedHost) {
return `${forwardedProto}://${forwardedHost}`;
}
return requestUrl.origin;
}
/**
* 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<Response> {
const proxyPath = stripTrailingSlashes(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);
const pathMatches = requestUrl.pathname === proxyPath || requestUrl.pathname.startsWith(proxyPath + '/');
if (!pathMatches) {
return createErrorResponse(
'proxy_path_mismatch',
`Request path "${requestUrl.pathname}" does not match proxy path "${proxyPath}"`,
400,
);
}
// Derive the FAPI URL and construct the target URL.
// Use string concatenation instead of `new URL(path, base)` to avoid
// protocol-relative resolution (e.g., "//evil.com" resolving to a different host).
const fapiBaseUrl = fapiUrlFromPublishableKey(publishableKey);
const fapiHost = new URL(fapiBaseUrl).host;
const targetPath = requestUrl.pathname.slice(proxyPath.length) || '/';
const targetUrl = new URL(`${fapiBaseUrl}${targetPath}`);
targetUrl.search = requestUrl.search;
if (targetUrl.host !== fapiHost) {
return createErrorResponse('proxy_request_failed', 'Resolved target does not match the expected host', 400);
}
// Build headers for the proxied request
const headers = new Headers();
// Copy original headers, excluding hop-by-hop headers and any
// dynamically-nominated hop-by-hop headers listed in the Connection header (RFC 7230 Section 6.1).
const dynamicHopByHop = getDynamicHopByHopHeaders(request.headers);
request.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.has(lower) && !dynamicHopByHop.has(lower)) {
headers.set(key, value);
}
});
// Set required Clerk proxy headers
// Use the public origin (from forwarded headers) so the Clerk-Proxy-Url
// points to the browser-visible host, not localhost behind a reverse proxy.
const publicOrigin = derivePublicOrigin(request, requestUrl);
const proxyUrl = `${publicOrigin}${proxyPath}`;
headers.set('Clerk-Proxy-Url', proxyUrl);
headers.set('Clerk-Secret-Key', secretKey);
// Set the host header to the FAPI host
headers.set('Host', fapiHost);
// Request uncompressed responses to avoid a double compression pass.
// fetch() auto-decompresses, so without this FAPI compresses → fetch
// decompresses → the serving layer re-compresses for the browser.
headers.set('Accept-Encoding', 'identity');
// Set X-Forwarded-* headers for proxy awareness
// Only set these if not already present (preserve values from upstream proxies)
if (!headers.has('X-Forwarded-Host')) {
headers.set('X-Forwarded-Host', requestUrl.host);
}
if (!headers.has('X-Forwarded-Proto')) {
headers.set('X-Forwarded-Proto', requestUrl.protocol.replace(':', ''));
}
// Set X-Forwarded-For to the client IP
// In multi-proxy scenarios, we prefer authoritative headers (CF-Connecting-IP, X-Real-IP)
// over the existing X-Forwarded-For chain, as they provide the true client IP
const clientIp = getClientIp(request);
if (clientIp) {
headers.set('X-Forwarded-For', clientIp);
}
// Determine if request has a body (handles DELETE-with-body and any other method)
const hasBody = request.body !== null;
try {
// Make the proxied request
// TODO: Consider adding AbortSignal.timeout(30_000) via AbortSignal.any()
const fetchOptions: RequestInit = {
method: request.method,
headers,
redirect: 'manual',
signal: request.signal,
};
// Only set duplex when body is present (required for streaming bodies)
if (hasBody) {
// @ts-expect-error - duplex is required for streaming bodies, but not present on the RequestInit type from undici
fetchOptions.duplex = 'half';
fetchOptions.body = request.body;
}
const response = await fetch(targetUrl.toString(), fetchOptions);
// Build response headers, excluding hop-by-hop and encoding headers.
// Also strip dynamically-nominated hop-by-hop headers from the response Connection header.
const responseDynamicHopByHop = getDynamicHopByHopHeaders(response.headers);
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (
!HOP_BY_HOP_HEADERS.has(lower) &&
!RESPONSE_HEADERS_TO_STRIP.has(lower) &&
!responseDynamicHopByHop.has(lower)
) {
if (lower === 'set-cookie') {
responseHeaders.append(key, value);
} else {
responseHeaders.set(key, value);
}
}
});
// Rewrite Location header for redirects to go through the proxy
const locationHeader = response.headers.get('location');
if (locationHeader) {
try {
const locationUrl = new URL(locationHeader, fapiBaseUrl);
// Check if the redirect points to the FAPI host
if (locationUrl.host === fapiHost) {
// Rewrite to go through the proxy
const rewrittenLocation = `${proxyUrl}${locationUrl.pathname}${locationUrl.search}${locationUrl.hash}`;
responseHeaders.set('Location', rewrittenLocation);
}
} catch {
// If URL parsing fails, leave the Location header as-is (could be a relative URL)
}
}
const proxyResponse = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
// Some runtimes may re-add Content-Length when constructing the Response.
// Delete explicitly since fetch() decoded the body and the original values
// no longer reflect the actual content.
for (const header of RESPONSE_HEADERS_TO_STRIP) {
proxyResponse.headers.delete(header);
}
return proxyResponse;
} 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);
}
}