Skip to content

Commit 387d855

Browse files
committed
feat(hono): add Frontend API proxy support
Add `frontendApiProxy` option to `clerkMiddleware` that intercepts requests matching the proxy path (default `/__clerk`) and forwards them to Clerk's Frontend API. When enabled, proxyUrl is automatically derived so SDKs pass just the path to the backend for resolution.
1 parent de089c5 commit 387d855

5 files changed

Lines changed: 279 additions & 7 deletions

File tree

.changeset/hono-proxy-support.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/hono': minor
3+
---
4+
5+
Add Frontend API proxy support to `@clerk/hono` via the `frontendApiProxy` option on `clerkMiddleware`. When enabled, requests matching the proxy path (default `/__clerk`) are forwarded to Clerk's Frontend API, allowing Clerk to work in environments where direct API access is blocked by ad blockers or firewalls. The `proxyUrl` for auth handshake is automatically derived from the request when `frontendApiProxy` is configured.

packages/hono/src/__tests__/clerkMiddleware.test.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ const createMockSessionAuth = () => ({
1818

1919
const authenticateRequestMock = vi.fn();
2020

21+
const { mockClerkFrontendApiProxy } = vi.hoisted(() => ({
22+
mockClerkFrontendApiProxy: vi.fn(),
23+
}));
24+
25+
vi.mock('@clerk/backend/proxy', async () => {
26+
const actual = await vi.importActual('@clerk/backend/proxy');
27+
return {
28+
...actual,
29+
clerkFrontendApiProxy: mockClerkFrontendApiProxy,
30+
};
31+
});
32+
2133
vi.mock(import('@clerk/backend'), async importOriginal => {
2234
const original = await importOriginal();
2335

@@ -163,6 +175,211 @@ describe('clerkMiddleware()', () => {
163175
});
164176
});
165177

178+
describe('Frontend API proxy handling', () => {
179+
beforeEach(() => {
180+
vi.stubEnv('CLERK_SECRET_KEY', EnvVariables.CLERK_SECRET_KEY);
181+
vi.stubEnv('CLERK_PUBLISHABLE_KEY', EnvVariables.CLERK_PUBLISHABLE_KEY);
182+
authenticateRequestMock.mockReset();
183+
mockClerkFrontendApiProxy.mockReset();
184+
});
185+
186+
test('intercepts proxy path requests', async () => {
187+
mockClerkFrontendApiProxy.mockResolvedValueOnce(new Response('proxied', { status: 200 }));
188+
189+
const app = new Hono();
190+
app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: true } }));
191+
app.get('/*', c => c.text('OK'));
192+
193+
const response = await app.request(new Request('http://localhost/__clerk/v1/client'));
194+
195+
expect(response.status).toEqual(200);
196+
expect(await response.text()).toEqual('proxied');
197+
expect(mockClerkFrontendApiProxy).toHaveBeenCalled();
198+
expect(authenticateRequestMock).not.toHaveBeenCalled();
199+
});
200+
201+
test('intercepts proxy path with query parameters', async () => {
202+
mockClerkFrontendApiProxy.mockResolvedValueOnce(new Response('proxied', { status: 200 }));
203+
204+
const app = new Hono();
205+
app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: true } }));
206+
app.get('/*', c => c.text('OK'));
207+
208+
const response = await app.request(new Request('http://localhost/__clerk?_clerk_js_version=5.0.0'));
209+
210+
expect(response.status).toEqual(200);
211+
expect(await response.text()).toEqual('proxied');
212+
expect(mockClerkFrontendApiProxy).toHaveBeenCalled();
213+
expect(authenticateRequestMock).not.toHaveBeenCalled();
214+
});
215+
216+
test('authenticates default path when custom proxy path is set', async () => {
217+
authenticateRequestMock.mockResolvedValueOnce({
218+
status: 'handshake',
219+
reason: 'auth-reason',
220+
message: 'auth-message',
221+
headers: new Headers({
222+
location: 'https://fapi.example.com/v1/clients/handshake',
223+
'x-clerk-auth-message': 'auth-message',
224+
'x-clerk-auth-reason': 'auth-reason',
225+
'x-clerk-auth-status': 'handshake',
226+
}),
227+
toAuth: createMockSessionAuth,
228+
});
229+
230+
const app = new Hono();
231+
app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: true, path: '/custom-proxy' } }));
232+
app.get('/*', c => c.text('OK'));
233+
234+
const response = await app.request(
235+
new Request('http://localhost/__clerk/v1/client', {
236+
headers: {
237+
Cookie: '__client_uat=1711618859;',
238+
'Sec-Fetch-Dest': 'document',
239+
},
240+
}),
241+
);
242+
243+
expect(response.status).toEqual(307);
244+
expect(response.headers.get('x-clerk-auth-status')).toEqual('handshake');
245+
expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled();
246+
expect(authenticateRequestMock).toHaveBeenCalled();
247+
});
248+
249+
test('does not intercept when enabled is false', async () => {
250+
authenticateRequestMock.mockResolvedValueOnce({
251+
headers: new Headers(),
252+
toAuth: createMockSessionAuth,
253+
});
254+
255+
const app = new Hono();
256+
app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: false } }));
257+
app.get('/*', c => c.text('OK'));
258+
259+
const response = await app.request(new Request('http://localhost/__clerk/v1/client'));
260+
261+
expect(response.status).toEqual(200);
262+
expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled();
263+
expect(authenticateRequestMock).toHaveBeenCalled();
264+
});
265+
266+
test('does not intercept when frontendApiProxy is not configured', async () => {
267+
authenticateRequestMock.mockResolvedValueOnce({
268+
headers: new Headers(),
269+
toAuth: createMockSessionAuth,
270+
});
271+
272+
const app = new Hono();
273+
app.use('*', clerkMiddleware());
274+
app.get('/*', c => c.text('OK'));
275+
276+
const response = await app.request(new Request('http://localhost/__clerk/v1/client'));
277+
278+
expect(response.status).toEqual(200);
279+
expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled();
280+
expect(authenticateRequestMock).toHaveBeenCalled();
281+
});
282+
283+
test('still authenticates non-proxy paths when proxy is configured', async () => {
284+
authenticateRequestMock.mockResolvedValueOnce({
285+
headers: new Headers(),
286+
toAuth: createMockSessionAuth,
287+
});
288+
289+
const app = new Hono();
290+
app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: true } }));
291+
app.get('/*', c => c.text('OK'));
292+
293+
const response = await app.request(new Request('http://localhost/api/users'));
294+
295+
expect(response.status).toEqual(200);
296+
expect(await response.text()).toEqual('OK');
297+
expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled();
298+
expect(authenticateRequestMock).toHaveBeenCalled();
299+
});
300+
301+
test('uses env vars for keys when only frontendApiProxy is passed', async () => {
302+
mockClerkFrontendApiProxy.mockResolvedValueOnce(new Response('proxied', { status: 200 }));
303+
304+
const app = new Hono();
305+
app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: true } }));
306+
app.get('/*', c => c.text('OK'));
307+
308+
const response = await app.request(new Request('http://localhost/__clerk/v1/client'));
309+
310+
expect(response.status).toEqual(200);
311+
expect(mockClerkFrontendApiProxy).toHaveBeenCalledWith(
312+
expect.any(Request),
313+
expect.objectContaining({
314+
publishableKey: EnvVariables.CLERK_PUBLISHABLE_KEY,
315+
secretKey: EnvVariables.CLERK_SECRET_KEY,
316+
}),
317+
);
318+
});
319+
320+
test('auto-derives proxyUrl from request when frontendApiProxy is enabled', async () => {
321+
authenticateRequestMock.mockResolvedValueOnce({
322+
headers: new Headers(),
323+
toAuth: createMockSessionAuth,
324+
});
325+
326+
const app = new Hono();
327+
app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: true } }));
328+
app.get('/*', c => c.text('OK'));
329+
330+
const response = await app.request(
331+
new Request('http://localhost/api/users', {
332+
headers: {
333+
'x-forwarded-proto': 'https',
334+
'x-forwarded-host': 'myapp.com',
335+
},
336+
}),
337+
);
338+
339+
expect(response.status).toEqual(200);
340+
expect(authenticateRequestMock).toHaveBeenCalledWith(
341+
expect.any(Request),
342+
expect.objectContaining({
343+
proxyUrl: '/__clerk',
344+
}),
345+
);
346+
});
347+
348+
test('does not override explicit proxyUrl', async () => {
349+
authenticateRequestMock.mockResolvedValueOnce({
350+
headers: new Headers(),
351+
toAuth: createMockSessionAuth,
352+
});
353+
354+
const app = new Hono();
355+
app.use(
356+
'*',
357+
clerkMiddleware({
358+
frontendApiProxy: { enabled: true },
359+
proxyUrl: 'https://explicit.example.com/__clerk',
360+
}),
361+
);
362+
app.get('/*', c => c.text('OK'));
363+
364+
const response = await app.request(
365+
new Request('http://localhost/api/users', {
366+
headers: {
367+
'x-forwarded-proto': 'https',
368+
'x-forwarded-host': 'myapp.com',
369+
},
370+
}),
371+
);
372+
373+
expect(response.status).toEqual(200);
374+
expect(authenticateRequestMock).toHaveBeenCalledWith(
375+
expect.any(Request),
376+
expect.objectContaining({
377+
proxyUrl: 'https://explicit.example.com/__clerk',
378+
}),
379+
);
380+
});
381+
});
382+
166383
describe('getAuth()', () => {
167384
beforeEach(() => {
168385
vi.stubEnv('CLERK_SECRET_KEY', EnvVariables.CLERK_SECRET_KEY);

packages/hono/src/clerkMiddleware.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@ import type { AuthObject } from '@clerk/backend';
22
import { createClerkClient } from '@clerk/backend';
33
import type { AuthenticateRequestOptions, AuthOptions, GetAuthFnNoRequest } from '@clerk/backend/internal';
44
import { getAuthObjectForAcceptedToken } from '@clerk/backend/internal';
5+
import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath, stripTrailingSlashes } from '@clerk/backend/proxy';
56
import type { MiddlewareHandler } from 'hono';
67
import { env } from 'hono/adapter';
78

9+
import type { FrontendApiProxyOptions } from './types';
10+
811
type ClerkEnv = {
912
CLERK_SECRET_KEY: string;
1013
CLERK_PUBLISHABLE_KEY: string;
1114
CLERK_API_URL?: string;
1215
CLERK_API_VERSION?: string;
1316
};
1417

15-
export type ClerkMiddlewareOptions = Omit<AuthenticateRequestOptions, 'acceptsToken'>;
18+
export type ClerkMiddlewareOptions = Omit<AuthenticateRequestOptions, 'acceptsToken'> & {
19+
frontendApiProxy?: FrontendApiProxyOptions;
20+
};
1621

1722
/**
1823
* Clerk middleware for Hono that authenticates requests and attaches
@@ -35,12 +40,14 @@ export type ClerkMiddlewareOptions = Omit<AuthenticateRequestOptions, 'acceptsTo
3540
export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareHandler => {
3641
return async (c, next) => {
3742
const clerkEnv = env<ClerkEnv>(c);
38-
const { secretKey, publishableKey, apiUrl, apiVersion, ...rest } = options || {
39-
secretKey: clerkEnv.CLERK_SECRET_KEY || '',
40-
publishableKey: clerkEnv.CLERK_PUBLISHABLE_KEY || '',
41-
apiUrl: clerkEnv.CLERK_API_URL,
42-
apiVersion: clerkEnv.CLERK_API_VERSION,
43-
};
43+
const {
44+
secretKey = clerkEnv.CLERK_SECRET_KEY || '',
45+
publishableKey = clerkEnv.CLERK_PUBLISHABLE_KEY || '',
46+
apiUrl = clerkEnv.CLERK_API_URL,
47+
apiVersion = clerkEnv.CLERK_API_VERSION,
48+
frontendApiProxy,
49+
...rest
50+
} = options || {};
4451

4552
if (!secretKey) {
4653
throw new Error(
@@ -54,6 +61,31 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareHan
5461
);
5562
}
5663

64+
// Handle Frontend API proxy requests and auto-derive proxyUrl
65+
let derivedProxyUrl = rest.proxyUrl;
66+
if (frontendApiProxy) {
67+
const proxyPath = stripTrailingSlashes(frontendApiProxy.path ?? DEFAULT_PROXY_PATH);
68+
const requestUrl = new URL(c.req.url);
69+
const isEnabled =
70+
typeof frontendApiProxy.enabled === 'function'
71+
? frontendApiProxy.enabled(requestUrl)
72+
: frontendApiProxy.enabled;
73+
74+
if (isEnabled) {
75+
if (matchProxyPath(c.req.raw, { proxyPath })) {
76+
return clerkFrontendApiProxy(c.req.raw, {
77+
proxyPath,
78+
publishableKey,
79+
secretKey,
80+
});
81+
}
82+
83+
if (!derivedProxyUrl) {
84+
derivedProxyUrl = proxyPath;
85+
}
86+
}
87+
}
88+
5789
const clerkClient = createClerkClient({
5890
...rest,
5991
apiUrl,
@@ -67,6 +99,7 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareHan
6799
...rest,
68100
secretKey,
69101
publishableKey,
102+
proxyUrl: derivedProxyUrl,
70103
acceptsToken: 'any',
71104
});
72105

packages/hono/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { clerkMiddleware } from './clerkMiddleware';
22
export type { ClerkMiddlewareOptions } from './clerkMiddleware';
3+
export type { FrontendApiProxyOptions } from './types';
34

45
export { getAuth } from './getAuth';
56

packages/hono/src/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ClerkClient } from '@clerk/backend';
22
import type { GetAuthFnNoRequest } from '@clerk/backend/internal';
3+
import type { ShouldProxyFn } from '@clerk/shared/proxy';
34

45
/**
56
* Variables that clerkMiddleware sets on the Hono context.
@@ -9,3 +10,18 @@ export type ClerkHonoVariables = {
910
clerk: ClerkClient;
1011
clerkAuth: GetAuthFnNoRequest;
1112
};
13+
14+
/**
15+
* Options for the built-in Frontend API proxy.
16+
*
17+
* When enabled, the middleware intercepts requests that match the proxy path
18+
* (default `/__clerk`) and forwards them to the Clerk Frontend API, allowing
19+
* the Clerk frontend SDKs to communicate with Clerk without third-party
20+
* cookie or ad-blocker issues.
21+
*/
22+
export interface FrontendApiProxyOptions {
23+
/** Toggle the proxy on/off, or supply a function that decides per-request. */
24+
enabled: boolean | ShouldProxyFn;
25+
/** Custom path prefix for the proxy (default: `/__clerk`). */
26+
path?: string;
27+
}

0 commit comments

Comments
 (0)