Skip to content

Commit f8f0f76

Browse files
authored
Merge branch 'main' into chris/mobile-405-react-native-components-release
2 parents e5b3a67 + a8c64cc commit f8f0f76

32 files changed

Lines changed: 1082 additions & 37 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': patch
3+
---
4+
5+
Fix `clerkFrontendApiProxy` to derive the `Clerk-Proxy-Url` header and Location rewrites from `x-forwarded-proto`/`x-forwarded-host` headers instead of the raw `request.url`. Behind a reverse proxy, `request.url` resolves to localhost, causing FAPI to receive an incorrect proxy URL. The fix uses the same forwarded-header resolution pattern as `ClerkRequest`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/fastify': minor
3+
---
4+
5+
Add Frontend API proxy support to `@clerk/fastify` via the `frontendApiProxy` option on `clerkPlugin`. 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.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/shared': patch
4+
---
5+
6+
Narrow the error conditions that trigger the unauthenticated flow (sign-out) to only high-confidence authentication failures (401, 422). Previously, all 4xx errors — including 429 rate limits — were treated as auth failures, which could sign users out during transient rate limiting. Non-auth errors from `setActive` now propagate to the caller instead of being silently swallowed.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/express': patch
3+
---
4+
5+
Fix empty path fallback for `frontendApiProxy` to prevent intercepting all requests when `path` resolves to an empty string

.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.

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ jobs:
305305
"nuxt",
306306
"react-router",
307307
"custom",
308+
"hono",
308309
]
309310
test-project: ["chrome"]
310311
include:

integration/presets/envs.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ const withWaitlistMode = withEmailCodes
136136
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-waitlist-mode').sk)
137137
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-waitlist-mode').pk);
138138

139+
const withEmailCodesProxy = withEmailCodes
140+
.clone()
141+
.setId('withEmailCodesProxy')
142+
.setEnvVariable('private', 'CLERK_PROXY_ENABLED', 'true');
143+
139144
const withSignInOrUpFlow = withEmailCodes
140145
.clone()
141146
.setId('withSignInOrUpFlow')
@@ -222,6 +227,7 @@ export const envs = {
222227
withDynamicKeys,
223228
withEmailCodes,
224229
withEmailCodes_destroy_client,
230+
withEmailCodesProxy,
225231
withEmailCodesQuickstart,
226232
withEmailLinks,
227233
withKeyless,

integration/presets/longRunningApps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export const createLongRunningApps = () => {
8686
* Hono apps
8787
*/
8888
{ id: 'hono.vite.withEmailCodes', config: hono.vite, env: envs.withEmailCodes },
89+
{ id: 'hono.vite.withEmailCodesProxy', config: hono.vite, env: envs.withEmailCodesProxy },
8990
] as const;
9091

9192
const apps = configs.map(longRunningApplication);

integration/templates/hono-vite/src/server/main.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ import ViteExpress from 'vite-express';
88

99
const app = new Hono();
1010

11+
const proxyEnabled = process.env.CLERK_PROXY_ENABLED === 'true';
12+
1113
app.use(
1214
'*',
1315
clerkMiddleware({
1416
publishableKey: process.env.VITE_CLERK_PUBLISHABLE_KEY,
17+
...(proxyEnabled ? { frontendApiProxy: { enabled: true } } : {}),
1518
}),
1619
);
1720

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { appConfigs } from '../../presets';
4+
import type { FakeUser } from '../../testUtils';
5+
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
6+
7+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodesProxy] })(
8+
'frontend API proxy tests for @hono',
9+
({ app }) => {
10+
test.describe.configure({ mode: 'parallel' });
11+
12+
let fakeUser: FakeUser;
13+
14+
test.beforeAll(async () => {
15+
const u = createTestUtils({ app });
16+
fakeUser = u.services.users.createFakeUser();
17+
await u.services.users.createBapiUser(fakeUser);
18+
});
19+
20+
test.afterAll(async () => {
21+
await fakeUser.deleteIfExists();
22+
await app.teardown();
23+
});
24+
25+
test('protected routes still require auth when proxy is enabled', async ({ page, context }) => {
26+
const u = createTestUtils({ app, page, context });
27+
await u.page.goToRelative('/');
28+
await u.po.signIn.waitForMounted();
29+
30+
const url = new URL('/api/protected', app.serverUrl);
31+
const res = await u.page.request.get(url.toString());
32+
expect(res.status()).toBe(401);
33+
expect(await res.text()).toBe('Unauthorized');
34+
});
35+
36+
test('authenticated requests work with proxy enabled', async ({ page, context }) => {
37+
const u = createTestUtils({ app, page, context });
38+
await u.page.goToRelative('/');
39+
40+
await u.po.signIn.waitForMounted();
41+
await u.po.signIn.setIdentifier(fakeUser.email);
42+
await u.po.signIn.continue();
43+
await u.po.signIn.setPassword(fakeUser.password);
44+
await u.po.signIn.continue();
45+
46+
await u.po.userButton.waitForMounted();
47+
48+
const url = new URL('/api/protected', app.serverUrl);
49+
const res = await u.page.request.get(url.toString());
50+
expect(res.status()).toBe(200);
51+
expect(await res.text()).toBe('Protected API response');
52+
});
53+
54+
test('handshake redirect uses forwarded headers for proxyUrl, not localhost', async () => {
55+
// This test proves that the SDK must derive proxyUrl from x-forwarded-* headers.
56+
// When a reverse proxy sits in front of the app, the raw request URL is localhost,
57+
// but the handshake redirect must point to the public origin.
58+
//
59+
// We simulate a behind-proxy scenario by sending x-forwarded-proto and x-forwarded-host
60+
// headers, with a __client_uat cookie (non-zero) but no session cookie, which forces
61+
// a handshake. The handshake redirect Location should use the forwarded origin.
62+
const url = new URL('/api/protected', app.serverUrl);
63+
const res = await fetch(url.toString(), {
64+
headers: {
65+
'x-forwarded-proto': 'https',
66+
'x-forwarded-host': 'myapp.example.com',
67+
'sec-fetch-dest': 'document',
68+
Accept: 'text/html',
69+
Cookie: '__clerk_db_jwt=needstobeset; __client_uat=1',
70+
},
71+
redirect: 'manual',
72+
});
73+
74+
// The server should respond with a 307 handshake redirect
75+
expect(res.status).toBe(307);
76+
const location = res.headers.get('location') ?? '';
77+
// The redirect must point to the public origin (from forwarded headers),
78+
// NOT to http://localhost:PORT. If the SDK uses requestUrl.origin instead
79+
// of forwarded headers, this assertion will fail.
80+
expect(location).toContain('https://myapp.example.com');
81+
expect(location).not.toContain('localhost');
82+
});
83+
},
84+
);

0 commit comments

Comments
 (0)