diff --git a/.changeset/backend-proxy-forwarded-headers.md b/.changeset/backend-proxy-forwarded-headers.md new file mode 100644 index 00000000000..734f171b5f8 --- /dev/null +++ b/.changeset/backend-proxy-forwarded-headers.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +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`. diff --git a/.changeset/fastify-proxy-support.md b/.changeset/fastify-proxy-support.md new file mode 100644 index 00000000000..5cb4587b0af --- /dev/null +++ b/.changeset/fastify-proxy-support.md @@ -0,0 +1,5 @@ +--- +'@clerk/fastify': minor +--- + +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. diff --git a/.changeset/hono-proxy-support.md b/.changeset/hono-proxy-support.md new file mode 100644 index 00000000000..17820cd28e8 --- /dev/null +++ b/.changeset/hono-proxy-support.md @@ -0,0 +1,5 @@ +--- +'@clerk/hono': minor +--- + +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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af4636a3679..7addc384b0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -305,6 +305,7 @@ jobs: "nuxt", "react-router", "custom", + "hono", ] test-project: ["chrome"] include: diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 9d855b1db8b..eac5f2c938a 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -136,6 +136,11 @@ const withWaitlistMode = withEmailCodes .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-waitlist-mode').sk) .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-waitlist-mode').pk); +const withEmailCodesProxy = withEmailCodes + .clone() + .setId('withEmailCodesProxy') + .setEnvVariable('private', 'CLERK_PROXY_ENABLED', 'true'); + const withSignInOrUpFlow = withEmailCodes .clone() .setId('withSignInOrUpFlow') @@ -222,6 +227,7 @@ export const envs = { withDynamicKeys, withEmailCodes, withEmailCodes_destroy_client, + withEmailCodesProxy, withEmailCodesQuickstart, withEmailLinks, withKeyless, diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index d47f23894c9..9f4caa4b16d 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -86,6 +86,7 @@ export const createLongRunningApps = () => { * Hono apps */ { id: 'hono.vite.withEmailCodes', config: hono.vite, env: envs.withEmailCodes }, + { id: 'hono.vite.withEmailCodesProxy', config: hono.vite, env: envs.withEmailCodesProxy }, ] as const; const apps = configs.map(longRunningApplication); diff --git a/integration/templates/hono-vite/src/server/main.ts b/integration/templates/hono-vite/src/server/main.ts index 3144074b808..8128c4b0d7b 100644 --- a/integration/templates/hono-vite/src/server/main.ts +++ b/integration/templates/hono-vite/src/server/main.ts @@ -8,10 +8,13 @@ import ViteExpress from 'vite-express'; const app = new Hono(); +const proxyEnabled = process.env.CLERK_PROXY_ENABLED === 'true'; + app.use( '*', clerkMiddleware({ publishableKey: process.env.VITE_CLERK_PUBLISHABLE_KEY, + ...(proxyEnabled ? { frontendApiProxy: { enabled: true } } : {}), }), ); diff --git a/integration/tests/hono/proxy.test.ts b/integration/tests/hono/proxy.test.ts new file mode 100644 index 00000000000..50e0eedb049 --- /dev/null +++ b/integration/tests/hono/proxy.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodesProxy] })( + 'frontend API proxy tests for @hono', + ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('protected routes still require auth when proxy is enabled', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/'); + await u.po.signIn.waitForMounted(); + + const url = new URL('/api/protected', app.serverUrl); + const res = await u.page.request.get(url.toString()); + expect(res.status()).toBe(401); + expect(await res.text()).toBe('Unauthorized'); + }); + + test('authenticated requests work with proxy enabled', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/'); + + await u.po.signIn.waitForMounted(); + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + + await u.po.userButton.waitForMounted(); + + const url = new URL('/api/protected', app.serverUrl); + const res = await u.page.request.get(url.toString()); + expect(res.status()).toBe(200); + expect(await res.text()).toBe('Protected API response'); + }); + + test('handshake redirect uses forwarded headers for proxyUrl, not localhost', async () => { + // This test proves that the SDK must derive proxyUrl from x-forwarded-* headers. + // When a reverse proxy sits in front of the app, the raw request URL is localhost, + // but the handshake redirect must point to the public origin. + // + // We simulate a behind-proxy scenario by sending x-forwarded-proto and x-forwarded-host + // headers, with a __client_uat cookie (non-zero) but no session cookie, which forces + // a handshake. The handshake redirect Location should use the forwarded origin. + const url = new URL('/api/protected', app.serverUrl); + const res = await fetch(url.toString(), { + headers: { + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'myapp.example.com', + 'sec-fetch-dest': 'document', + Accept: 'text/html', + Cookie: '__clerk_db_jwt=needstobeset; __client_uat=1', + }, + redirect: 'manual', + }); + + // The server should respond with a 307 handshake redirect + expect(res.status).toBe(307); + const location = res.headers.get('location') ?? ''; + // The redirect must point to the public origin (from forwarded headers), + // NOT to http://localhost:PORT. If the SDK uses requestUrl.origin instead + // of forwarded headers, this assertion will fail. + expect(location).toContain('https://myapp.example.com'); + expect(location).not.toContain('localhost'); + }); + }, +); diff --git a/packages/backend/src/__tests__/proxy.test.ts b/packages/backend/src/__tests__/proxy.test.ts index ef76a722161..fdc54b47f51 100644 --- a/packages/backend/src/__tests__/proxy.test.ts +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -380,6 +380,67 @@ describe('proxy', () => { expect(options.headers.get('X-Forwarded-Proto')).toBe('https'); }); + it('derives Clerk-Proxy-Url from forwarded headers instead of localhost', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }); + mockFetch.mockResolvedValue(mockResponse); + + // Behind a reverse proxy, request.url is localhost but forwarded headers carry the public origin + const request = new Request('http://localhost:3000/__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]; + expect(options.headers.get('Clerk-Proxy-Url')).toBe('https://myapp.example.com/__clerk'); + }); + + it('falls back to request URL for Clerk-Proxy-Url when no forwarded 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('Clerk-Proxy-Url')).toBe('https://example.com/__clerk'); + }); + + it('rewrites Location header using forwarded origin, not localhost', 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('http://localhost:3000/__clerk/v1/oauth/authorize', { + headers: { + 'X-Forwarded-Host': 'myapp.example.com', + 'X-Forwarded-Proto': 'https', + }, + }); + + 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://myapp.example.com/__clerk/v1/oauth/callback?code=123'); + }); + it('rewrites Location header for redirects pointing to FAPI', async () => { const mockResponse = new Response(null, { status: 302, diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts index d1a09f71ebb..96b6bad11a3 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -111,6 +111,22 @@ function createErrorResponse(code: ProxyErrorCode, message: string, status: numb }); } +/** + * 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 */ @@ -208,7 +224,10 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend }); // Set required Clerk proxy headers - const proxyUrl = `${requestUrl.protocol}//${requestUrl.host}${proxyPath}`; + // 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); diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts index 10b4a02bf62..b640a07ea79 100644 --- a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -214,6 +214,50 @@ describe('AuthenticateContext', () => { }); }); + describe('relative proxyUrl resolution', () => { + it('resolves relative proxyUrl against forwarded origin', async () => { + const headers = new Headers({ + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'myapp.example.com', + }); + const clerkRequest = createClerkRequest(new Request('http://localhost:3000/path', { headers })); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkLive, + proxyUrl: '/__clerk', + }); + + // initPublishableKeyValues resolves it for parsePublishableKey (strips protocol) + expect(context.frontendApi).toBe('https://myapp.example.com/__clerk'); + // post-Object.assign resolves this.proxyUrl + expect(context.proxyUrl).toBe('https://myapp.example.com/__clerk'); + }); + + it('does not modify absolute proxyUrl', async () => { + const headers = new Headers({ + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'myapp.example.com', + }); + const clerkRequest = createClerkRequest(new Request('http://localhost:3000/path', { headers })); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkLive, + proxyUrl: 'https://custom.proxy.com/__clerk', + }); + + expect(context.frontendApi).toBe('https://custom.proxy.com/__clerk'); + expect(context.proxyUrl).toBe('https://custom.proxy.com/__clerk'); + }); + + it('resolves relative proxyUrl without forwarded headers using request origin', async () => { + const clerkRequest = createClerkRequest(new Request('http://localhost:3000/path')); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkLive, + proxyUrl: '/__clerk', + }); + + expect(context.proxyUrl).toBe('http://localhost:3000/__clerk'); + }); + }); + // Added these tests to verify that the generated sha-1 is the same as the one used in cookie assignment // Tests copied from packages/shared/src/__tests__/keys.test.ts describe('getCookieSuffix(publishableKey, subtle)', () => { diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 6d9fdcb9c5a..55c0ed6ad21 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -85,6 +85,11 @@ class AuthenticateContext implements AuthenticateContext { Object.assign(this, options); this.clerkUrl = this.clerkRequest.clerkUrl; + + // Resolve relative proxyUrl to absolute using the request's public origin. + if (this.proxyUrl?.startsWith('/')) { + this.proxyUrl = `${this.clerkUrl.origin}${this.proxyUrl}`; + } } public usesSuffixedCookies(): boolean { @@ -250,6 +255,14 @@ class AuthenticateContext implements AuthenticateContext { assertValidPublishableKey(options.publishableKey); this.publishableKey = options.publishableKey; + // If proxyUrl is a relative path (e.g. '/__clerk'), resolve it against the + // request's public origin (derived from x-forwarded-* headers by ClerkRequest). + // This lets SDKs pass just the path instead of duplicating forwarded-header parsing. + let resolvedProxyUrl = options.proxyUrl; + if (resolvedProxyUrl?.startsWith('/')) { + resolvedProxyUrl = `${this.clerkRequest.clerkUrl.origin}${resolvedProxyUrl}`; + } + const originalPk = parsePublishableKey(this.publishableKey, { fatal: true, domain: options.domain, @@ -259,7 +272,7 @@ class AuthenticateContext implements AuthenticateContext { const pk = parsePublishableKey(this.publishableKey, { fatal: true, - proxyUrl: options.proxyUrl, + proxyUrl: resolvedProxyUrl, domain: options.domain, isSatellite: options.isSatellite, }); diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts index 4002d82a821..e488abecaf4 100644 --- a/packages/express/src/authenticateRequest.ts +++ b/packages/express/src/authenticateRequest.ts @@ -164,7 +164,8 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = } } - // Auto-derive proxyUrl from frontendApiProxy config if not explicitly set + // Pass the proxy path to authenticateRequest - the backend resolves it + // against the request's public origin (from x-forwarded-* headers). let resolvedOptions = options; if (frontendApiProxy && !options.proxyUrl) { const requestUrl = new URL(request.originalUrl || request.url, `http://${request.headers.host}`); @@ -173,17 +174,7 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = ? frontendApiProxy.enabled(requestUrl) : frontendApiProxy.enabled; if (isProxyEnabled) { - const forwardedProto = request.headers['x-forwarded-proto']; - const protoHeader = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; - const proto = (protoHeader || '').split(',')[0].trim(); - const protocol = request.secure || proto === 'https' ? 'https' : 'http'; - - const forwardedHost = request.headers['x-forwarded-host']; - const hostHeader = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost; - const host = (hostHeader || '').split(',')[0].trim() || request.headers.host || 'localhost'; - - const derivedProxyUrl = `${protocol}://${host}${proxyPath}`; - resolvedOptions = { ...options, proxyUrl: derivedProxyUrl }; + resolvedOptions = { ...options, proxyUrl: proxyPath }; } } diff --git a/packages/fastify/src/__tests__/frontendApiProxy.test.ts b/packages/fastify/src/__tests__/frontendApiProxy.test.ts new file mode 100644 index 00000000000..19afa6067a9 --- /dev/null +++ b/packages/fastify/src/__tests__/frontendApiProxy.test.ts @@ -0,0 +1,180 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; +import Fastify from 'fastify'; +import { vi } from 'vitest'; + +const { mockClerkFrontendApiProxy } = vi.hoisted(() => ({ + mockClerkFrontendApiProxy: vi.fn(), +})); + +vi.mock('@clerk/backend/proxy', async () => { + const actual = await vi.importActual('@clerk/backend/proxy'); + return { + ...actual, + clerkFrontendApiProxy: mockClerkFrontendApiProxy, + }; +}); + +const authenticateRequestMock = vi.fn(); + +vi.mock('@clerk/backend', async () => { + const actual = await vi.importActual('@clerk/backend'); + return { + ...actual, + createClerkClient: () => { + return { + authenticateRequest: (...args: any) => authenticateRequestMock(...args), + }; + }, + }; +}); + +import { clerkPlugin, getAuth } from '../index'; + +/** + * Helper that creates a Fastify instance with clerkPlugin registered, adds a + * catch-all route, and sends a request to the given path using inject(). + */ +async function injectOnPath( + pluginOptions: Parameters[1], + path: string, + headers: Record = {}, +) { + const fastify = Fastify(); + await fastify.register(clerkPlugin, pluginOptions); + + fastify.get('/*', (request: FastifyRequest, reply: FastifyReply) => { + const auth = getAuth(request); + reply.send({ auth }); + }); + + return fastify.inject({ + method: 'GET', + path, + headers, + }); +} + +function mockHandshakeResponse() { + authenticateRequestMock.mockResolvedValueOnce({ + status: 'handshake', + reason: 'auth-reason', + message: 'auth-message', + headers: new Headers({ + location: 'https://fapi.example.com/v1/clients/handshake', + 'x-clerk-auth-message': 'auth-message', + 'x-clerk-auth-reason': 'auth-reason', + 'x-clerk-auth-status': 'handshake', + }), + toAuth: () => ({ + tokenType: 'session_token', + }), + }); +} + +describe('Frontend API proxy handling', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + mockClerkFrontendApiProxy.mockReset(); + }); + + it('intercepts proxy path and forwards to clerkFrontendApiProxy', async () => { + mockClerkFrontendApiProxy.mockResolvedValueOnce(new globalThis.Response('proxied', { status: 200 })); + + const response = await injectOnPath({ frontendApiProxy: { enabled: true } }, '/__clerk/v1/client', {}); + + expect(response.statusCode).toEqual(200); + expect(mockClerkFrontendApiProxy).toHaveBeenCalled(); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }); + + it('intercepts proxy path with query parameters', async () => { + mockClerkFrontendApiProxy.mockResolvedValueOnce(new globalThis.Response('proxied', { status: 200 })); + + const response = await injectOnPath( + { frontendApiProxy: { enabled: true } }, + '/__clerk?_clerk_js_version=5.0.0', + {}, + ); + + expect(response.statusCode).toEqual(200); + expect(mockClerkFrontendApiProxy).toHaveBeenCalled(); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }); + + it('authenticates default path when custom proxy path is set', async () => { + mockHandshakeResponse(); + + const response = await injectOnPath( + { frontendApiProxy: { enabled: true, path: '/custom-clerk-proxy' } }, + '/__clerk/v1/client', + { + Cookie: '__client_uat=1711618859;', + 'Sec-Fetch-Dest': 'document', + }, + ); + + expect(response.statusCode).toEqual(307); + expect(response.headers).toHaveProperty('x-clerk-auth-status', 'handshake'); + expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled(); + }); + + it('authenticates proxy paths when enabled is false', async () => { + mockHandshakeResponse(); + + const response = await injectOnPath({ frontendApiProxy: { enabled: false } }, '/__clerk/v1/client', { + Cookie: '__client_uat=1711618859;', + 'Sec-Fetch-Dest': 'document', + }); + + expect(response.statusCode).toEqual(307); + expect(response.headers).toHaveProperty('x-clerk-auth-status', 'handshake'); + expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled(); + }); + + it('does not handle proxy paths when frontendApiProxy is not configured', async () => { + mockHandshakeResponse(); + + const response = await injectOnPath({}, '/__clerk/v1/client', { + Cookie: '__client_uat=1711618859;', + 'Sec-Fetch-Dest': 'document', + }); + + expect(response.statusCode).toEqual(307); + expect(response.headers).toHaveProperty('x-clerk-auth-status', 'handshake'); + expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled(); + }); + + it('still authenticates requests to other paths when proxy is configured', async () => { + mockHandshakeResponse(); + + const response = await injectOnPath({ frontendApiProxy: { enabled: true } }, '/api/users', { + Cookie: '__client_uat=1711618859;', + 'Sec-Fetch-Dest': 'document', + }); + + expect(response.statusCode).toEqual(307); + expect(response.headers).toHaveProperty('x-clerk-auth-status', 'handshake'); + expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled(); + }); + + it('auto-derives proxyUrl for authentication when proxy is enabled', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + headers: new Headers(), + toAuth: () => ({ + tokenType: 'session_token', + }), + }); + + await injectOnPath({ frontendApiProxy: { enabled: true } }, '/api/users', { + Host: 'myapp.example.com', + }); + + expect(authenticateRequestMock).toHaveBeenCalledWith( + expect.any(Request), + expect.objectContaining({ + proxyUrl: '/__clerk', + }), + ); + }); +}); diff --git a/packages/fastify/src/index.ts b/packages/fastify/src/index.ts index aedcb8e9fbc..ead1e311bd2 100644 --- a/packages/fastify/src/index.ts +++ b/packages/fastify/src/index.ts @@ -1,6 +1,6 @@ export * from '@clerk/backend'; -export type { ClerkFastifyOptions } from './types'; +export type { ClerkFastifyOptions, FrontendApiProxyOptions } from './types'; export { clerkPlugin } from './clerkPlugin'; export { getAuth } from './getAuth'; diff --git a/packages/fastify/src/types.ts b/packages/fastify/src/types.ts index ff53d0a69bf..7b1224ea271 100644 --- a/packages/fastify/src/types.ts +++ b/packages/fastify/src/types.ts @@ -1,7 +1,24 @@ import type { ClerkOptions } from '@clerk/backend'; +import type { ShouldProxyFn } from '@clerk/shared/proxy'; export const ALLOWED_HOOKS = ['onRequest', 'preHandler'] as const; +/** + * Options for the built-in Frontend API proxy. + * + * When enabled, the middleware intercepts requests that match the proxy path + * (default `/__clerk`) and forwards them to the Clerk Frontend API, allowing + * the Clerk frontend SDKs to communicate with Clerk without third-party + * cookie or ad-blocker issues. + */ +export interface FrontendApiProxyOptions { + /** Toggle the proxy on/off, or supply a function that decides per-request. */ + enabled: boolean | ShouldProxyFn; + /** Custom path prefix for the proxy (default: `/__clerk`). */ + path?: string; +} + export type ClerkFastifyOptions = ClerkOptions & { hookName?: (typeof ALLOWED_HOOKS)[number]; + frontendApiProxy?: FrontendApiProxyOptions; }; diff --git a/packages/fastify/src/utils.ts b/packages/fastify/src/utils.ts index 95ed4a08639..1b36da0ef9b 100644 --- a/packages/fastify/src/utils.ts +++ b/packages/fastify/src/utils.ts @@ -1,4 +1,5 @@ import type { FastifyRequest } from 'fastify'; +import { Readable } from 'stream'; export const fastifyRequestToRequest = (req: FastifyRequest): Request => { const headers = new Headers( @@ -26,3 +27,37 @@ export const fastifyRequestToRequest = (req: FastifyRequest): Request => { headers, }); }; + +/** + * Converts a Fastify request to a Fetch API Request with full headers and body streaming, + * suitable for proxy forwarding. + */ +export const requestToProxyRequest = (req: FastifyRequest): Request => { + const headers = new Headers(); + Object.entries(req.headers).forEach(([key, value]) => { + if (value) { + headers.set(key, Array.isArray(value) ? value.join(', ') : value); + } + }); + + const forwardedProto = req.headers['x-forwarded-proto']; + const protoHeader = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; + const proto = (protoHeader || '').split(',')[0].trim(); + const protocol = proto === 'https' || req.protocol === 'https' ? 'https' : 'http'; + + const forwardedHost = req.headers['x-forwarded-host']; + const hostHeader = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost; + const host = (hostHeader || '').split(',')[0].trim() || req.hostname || 'localhost'; + + const url = new URL(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.raw) 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/fastify/src/withClerkMiddleware.ts b/packages/fastify/src/withClerkMiddleware.ts index 8679e912cfb..bca237ce8d4 100644 --- a/packages/fastify/src/withClerkMiddleware.ts +++ b/packages/fastify/src/withClerkMiddleware.ts @@ -1,19 +1,84 @@ import { AuthStatus } from '@clerk/backend/internal'; +import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, stripTrailingSlashes } from '@clerk/backend/proxy'; import type { FastifyReply, FastifyRequest } from 'fastify'; +import { Readable } from 'stream'; import { clerkClient } from './clerkClient'; import * as constants from './constants'; import type { ClerkFastifyOptions } from './types'; -import { fastifyRequestToRequest } from './utils'; +import { fastifyRequestToRequest, requestToProxyRequest } from './utils'; export const withClerkMiddleware = (options: ClerkFastifyOptions) => { + const frontendApiProxy = options.frontendApiProxy; + const proxyPath = stripTrailingSlashes(frontendApiProxy?.path ?? DEFAULT_PROXY_PATH) || DEFAULT_PROXY_PATH; + return async (fastifyRequest: FastifyRequest, reply: FastifyReply) => { + const publishableKey = options.publishableKey || constants.PUBLISHABLE_KEY; + const secretKey = options.secretKey || constants.SECRET_KEY; + + // Handle Frontend API proxy requests and auto-derive proxyUrl + let resolvedProxyUrl = options.proxyUrl; + if (frontendApiProxy) { + const requestUrl = new URL( + fastifyRequest.url, + `${fastifyRequest.protocol}://${fastifyRequest.hostname || 'localhost'}`, + ); + const isEnabled = + typeof frontendApiProxy.enabled === 'function' + ? frontendApiProxy.enabled(requestUrl) + : frontendApiProxy.enabled; + + if (isEnabled) { + if (requestUrl.pathname === proxyPath || requestUrl.pathname.startsWith(proxyPath + '/')) { + const proxyRequest = requestToProxyRequest(fastifyRequest); + + const proxyResponse = await clerkFrontendApiProxy(proxyRequest, { + proxyPath, + publishableKey, + secretKey, + }); + + reply.code(proxyResponse.status); + proxyResponse.headers.forEach((value, key) => { + reply.header(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))); + } + }, + }); + return reply.send(stream); + } + return reply.send(); + } + + // Pass just the path - the backend resolves it against the request's + // public origin (from x-forwarded-* headers). + if (!resolvedProxyUrl) { + resolvedProxyUrl = proxyPath; + } + } + } + const req = fastifyRequestToRequest(fastifyRequest); const requestState = await clerkClient.authenticateRequest(req, { ...options, - secretKey: options.secretKey || constants.SECRET_KEY, - publishableKey: options.publishableKey || constants.PUBLISHABLE_KEY, + secretKey, + publishableKey, + proxyUrl: resolvedProxyUrl, acceptsToken: 'any', }); diff --git a/packages/hono/src/__tests__/clerkMiddleware.test.ts b/packages/hono/src/__tests__/clerkMiddleware.test.ts index 154196c7b14..d0e246fd554 100644 --- a/packages/hono/src/__tests__/clerkMiddleware.test.ts +++ b/packages/hono/src/__tests__/clerkMiddleware.test.ts @@ -18,6 +18,18 @@ const createMockSessionAuth = () => ({ const authenticateRequestMock = vi.fn(); +const { mockClerkFrontendApiProxy } = vi.hoisted(() => ({ + mockClerkFrontendApiProxy: vi.fn(), +})); + +vi.mock('@clerk/backend/proxy', async () => { + const actual = await vi.importActual('@clerk/backend/proxy'); + return { + ...actual, + clerkFrontendApiProxy: mockClerkFrontendApiProxy, + }; +}); + vi.mock(import('@clerk/backend'), async importOriginal => { const original = await importOriginal(); @@ -163,6 +175,226 @@ describe('clerkMiddleware()', () => { }); }); +describe('Frontend API proxy handling', () => { + beforeEach(() => { + vi.stubEnv('CLERK_SECRET_KEY', EnvVariables.CLERK_SECRET_KEY); + vi.stubEnv('CLERK_PUBLISHABLE_KEY', EnvVariables.CLERK_PUBLISHABLE_KEY); + authenticateRequestMock.mockReset(); + mockClerkFrontendApiProxy.mockReset(); + }); + + test('intercepts proxy path requests', async () => { + mockClerkFrontendApiProxy.mockResolvedValueOnce(new Response('proxied', { status: 200 })); + + const app = new Hono(); + app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: true } })); + app.get('/*', c => c.text('OK')); + + const response = await app.request(new Request('http://localhost/__clerk/v1/client')); + + expect(response.status).toEqual(200); + expect(await response.text()).toEqual('proxied'); + expect(mockClerkFrontendApiProxy).toHaveBeenCalled(); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }); + + test('intercepts proxy path with query parameters', async () => { + mockClerkFrontendApiProxy.mockResolvedValueOnce(new Response('proxied', { status: 200 })); + + const app = new Hono(); + app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: true } })); + app.get('/*', c => c.text('OK')); + + const response = await app.request(new Request('http://localhost/__clerk?_clerk_js_version=5.0.0')); + + expect(response.status).toEqual(200); + expect(await response.text()).toEqual('proxied'); + expect(mockClerkFrontendApiProxy).toHaveBeenCalled(); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }); + + test('authenticates default path when custom proxy path is set', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + status: 'handshake', + reason: 'auth-reason', + message: 'auth-message', + headers: new Headers({ + location: 'https://fapi.example.com/v1/clients/handshake', + 'x-clerk-auth-message': 'auth-message', + 'x-clerk-auth-reason': 'auth-reason', + 'x-clerk-auth-status': 'handshake', + }), + toAuth: createMockSessionAuth, + }); + + const app = new Hono(); + app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: true, path: '/custom-proxy' } })); + app.get('/*', c => c.text('OK')); + + const response = await app.request( + new Request('http://localhost/__clerk/v1/client', { + headers: { + Cookie: '__client_uat=1711618859;', + 'Sec-Fetch-Dest': 'document', + }, + }), + ); + + expect(response.status).toEqual(307); + expect(response.headers.get('x-clerk-auth-status')).toEqual('handshake'); + expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled(); + expect(authenticateRequestMock).toHaveBeenCalled(); + }); + + test('does not intercept when enabled is false', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + headers: new Headers(), + toAuth: createMockSessionAuth, + }); + + const app = new Hono(); + app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: false } })); + app.get('/*', c => c.text('OK')); + + const response = await app.request(new Request('http://localhost/__clerk/v1/client')); + + expect(response.status).toEqual(200); + expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled(); + expect(authenticateRequestMock).toHaveBeenCalled(); + }); + + test('does not intercept when frontendApiProxy is not configured', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + headers: new Headers(), + toAuth: createMockSessionAuth, + }); + + const app = new Hono(); + app.use('*', clerkMiddleware()); + app.get('/*', c => c.text('OK')); + + const response = await app.request(new Request('http://localhost/__clerk/v1/client')); + + expect(response.status).toEqual(200); + expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled(); + expect(authenticateRequestMock).toHaveBeenCalled(); + }); + + test('still authenticates non-proxy paths when proxy is configured', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + headers: new Headers(), + toAuth: createMockSessionAuth, + }); + + const app = new Hono(); + app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: true } })); + app.get('/*', c => c.text('OK')); + + const response = await app.request(new Request('http://localhost/api/users')); + + expect(response.status).toEqual(200); + expect(await response.text()).toEqual('OK'); + expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled(); + expect(authenticateRequestMock).toHaveBeenCalled(); + }); + + test('uses env vars for keys when only frontendApiProxy is passed', async () => { + mockClerkFrontendApiProxy.mockResolvedValueOnce(new Response('proxied', { status: 200 })); + + const app = new Hono(); + app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: true } })); + app.get('/*', c => c.text('OK')); + + const response = await app.request(new Request('http://localhost/__clerk/v1/client')); + + expect(response.status).toEqual(200); + expect(mockClerkFrontendApiProxy).toHaveBeenCalledWith( + expect.any(Request), + expect.objectContaining({ + publishableKey: EnvVariables.CLERK_PUBLISHABLE_KEY, + secretKey: EnvVariables.CLERK_SECRET_KEY, + }), + ); + }); + + test('auto-derives proxyUrl from request when frontendApiProxy is enabled', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + headers: new Headers(), + toAuth: createMockSessionAuth, + }); + + const app = new Hono(); + app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: true } })); + app.get('/*', c => c.text('OK')); + + const response = await app.request( + new Request('http://localhost/api/users', { + headers: { + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'myapp.com', + }, + }), + ); + + expect(response.status).toEqual(200); + expect(authenticateRequestMock).toHaveBeenCalledWith( + expect.any(Request), + expect.objectContaining({ + proxyUrl: '/__clerk', + }), + ); + }); + + test('does not override explicit proxyUrl', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + headers: new Headers(), + toAuth: createMockSessionAuth, + }); + + const app = new Hono(); + app.use( + '*', + clerkMiddleware({ + frontendApiProxy: { enabled: true }, + proxyUrl: 'https://explicit.example.com/__clerk', + }), + ); + app.get('/*', c => c.text('OK')); + + const response = await app.request( + new Request('http://localhost/api/users', { + headers: { + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'myapp.com', + }, + }), + ); + + expect(response.status).toEqual(200); + expect(authenticateRequestMock).toHaveBeenCalledWith( + expect.any(Request), + expect.objectContaining({ + proxyUrl: 'https://explicit.example.com/__clerk', + }), + ); + }); + + test('falls back to default proxy path when path reduces to empty string', async () => { + mockClerkFrontendApiProxy.mockResolvedValueOnce(new Response('proxied', { status: 200 })); + + const app = new Hono(); + app.use('*', clerkMiddleware({ frontendApiProxy: { enabled: true, path: '/' } })); + app.get('/*', c => c.text('OK')); + + // Should intercept /__clerk (the default), not match everything + const response = await app.request(new Request('http://localhost/__clerk/v1/client')); + + expect(response.status).toEqual(200); + expect(await response.text()).toEqual('proxied'); + expect(mockClerkFrontendApiProxy).toHaveBeenCalled(); + }); +}); + describe('getAuth()', () => { beforeEach(() => { vi.stubEnv('CLERK_SECRET_KEY', EnvVariables.CLERK_SECRET_KEY); diff --git a/packages/hono/src/clerkMiddleware.ts b/packages/hono/src/clerkMiddleware.ts index 81451046ba5..98b15182d6e 100644 --- a/packages/hono/src/clerkMiddleware.ts +++ b/packages/hono/src/clerkMiddleware.ts @@ -2,9 +2,12 @@ import type { AuthObject } from '@clerk/backend'; import { createClerkClient } from '@clerk/backend'; import type { AuthenticateRequestOptions, AuthOptions, GetAuthFnNoRequest } from '@clerk/backend/internal'; import { getAuthObjectForAcceptedToken } from '@clerk/backend/internal'; +import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath, stripTrailingSlashes } from '@clerk/backend/proxy'; import type { MiddlewareHandler } from 'hono'; import { env } from 'hono/adapter'; +import type { FrontendApiProxyOptions } from './types'; + type ClerkEnv = { CLERK_SECRET_KEY: string; CLERK_PUBLISHABLE_KEY: string; @@ -12,7 +15,9 @@ type ClerkEnv = { CLERK_API_VERSION?: string; }; -export type ClerkMiddlewareOptions = Omit; +export type ClerkMiddlewareOptions = Omit & { + frontendApiProxy?: FrontendApiProxyOptions; +}; /** * Clerk middleware for Hono that authenticates requests and attaches @@ -35,12 +40,14 @@ export type ClerkMiddlewareOptions = Omit { return async (c, next) => { const clerkEnv = env(c); - const { secretKey, publishableKey, apiUrl, apiVersion, ...rest } = options || { - secretKey: clerkEnv.CLERK_SECRET_KEY || '', - publishableKey: clerkEnv.CLERK_PUBLISHABLE_KEY || '', - apiUrl: clerkEnv.CLERK_API_URL, - apiVersion: clerkEnv.CLERK_API_VERSION, - }; + const { + secretKey = clerkEnv.CLERK_SECRET_KEY || '', + publishableKey = clerkEnv.CLERK_PUBLISHABLE_KEY || '', + apiUrl = clerkEnv.CLERK_API_URL, + apiVersion = clerkEnv.CLERK_API_VERSION, + frontendApiProxy, + ...rest + } = options || {}; if (!secretKey) { throw new Error( @@ -54,6 +61,31 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareHan ); } + // Handle Frontend API proxy requests and auto-derive proxyUrl + let derivedProxyUrl = rest.proxyUrl; + if (frontendApiProxy) { + const proxyPath = stripTrailingSlashes(frontendApiProxy.path ?? DEFAULT_PROXY_PATH) || DEFAULT_PROXY_PATH; + const requestUrl = new URL(c.req.url); + const isEnabled = + typeof frontendApiProxy.enabled === 'function' + ? frontendApiProxy.enabled(requestUrl) + : frontendApiProxy.enabled; + + if (isEnabled) { + if (matchProxyPath(c.req.raw, { proxyPath })) { + return clerkFrontendApiProxy(c.req.raw, { + proxyPath, + publishableKey, + secretKey, + }); + } + + if (!derivedProxyUrl) { + derivedProxyUrl = proxyPath; + } + } + } + const clerkClient = createClerkClient({ ...rest, apiUrl, @@ -67,6 +99,7 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareHan ...rest, secretKey, publishableKey, + proxyUrl: derivedProxyUrl, acceptsToken: 'any', }); diff --git a/packages/hono/src/index.ts b/packages/hono/src/index.ts index 948cce534bb..ad28342281b 100644 --- a/packages/hono/src/index.ts +++ b/packages/hono/src/index.ts @@ -1,5 +1,6 @@ export { clerkMiddleware } from './clerkMiddleware'; export type { ClerkMiddlewareOptions } from './clerkMiddleware'; +export type { FrontendApiProxyOptions } from './types'; export { getAuth } from './getAuth'; diff --git a/packages/hono/src/types.ts b/packages/hono/src/types.ts index e7a97727ad8..d6815e651d6 100644 --- a/packages/hono/src/types.ts +++ b/packages/hono/src/types.ts @@ -1,5 +1,6 @@ import type { ClerkClient } from '@clerk/backend'; import type { GetAuthFnNoRequest } from '@clerk/backend/internal'; +import type { ShouldProxyFn } from '@clerk/shared/proxy'; /** * Variables that clerkMiddleware sets on the Hono context. @@ -9,3 +10,18 @@ export type ClerkHonoVariables = { clerk: ClerkClient; clerkAuth: GetAuthFnNoRequest; }; + +/** + * Options for the built-in Frontend API proxy. + * + * When enabled, the middleware intercepts requests that match the proxy path + * (default `/__clerk`) and forwards them to the Clerk Frontend API, allowing + * the Clerk frontend SDKs to communicate with Clerk without third-party + * cookie or ad-blocker issues. + */ +export interface FrontendApiProxyOptions { + /** Toggle the proxy on/off, or supply a function that decides per-request. */ + enabled: boolean | ShouldProxyFn; + /** Custom path prefix for the proxy (default: `/__clerk`). */ + path?: string; +}