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/packages/fastify/src/__tests__/__snapshots__/clerkClient.test.ts.snap b/packages/fastify/src/__tests__/__snapshots__/clerkClient.test.ts.snap deleted file mode 100644 index 9565b0a0d2f..00000000000 --- a/packages/fastify/src/__tests__/__snapshots__/clerkClient.test.ts.snap +++ /dev/null @@ -1,20 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`clerk initializes clerk with constants 1`] = ` -[ - [ - { - "apiUrl": "https://api.clerk.com", - "apiVersion": "v1", - "jwtKey": "", - "sdkMetadata": { - "environment": "test", - "name": "@clerk/fastify", - "version": "0.0.0-test", - }, - "secretKey": "TEST_SECRET_KEY", - "userAgent": "@clerk/fastify@0.0.0-test", - }, - ], -] -`; diff --git a/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap b/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap index 5d76837170b..5fdf83d5d33 100644 --- a/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap +++ b/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap @@ -15,18 +15,3 @@ exports[`constants > from environment variables 1`] = ` "SECRET_KEY": "TEST_SECRET_KEY", } `; - -exports[`constants from environment variables 1`] = ` -{ - "API_URL": "CLERK_API_URL", - "API_VERSION": "CLERK_API_VERSION", - "JWT_KEY": "CLERK_JWT_KEY", - "PUBLISHABLE_KEY": "CLERK_PUBLISHABLE_KEY", - "SDK_METADATA": { - "environment": "test", - "name": "@clerk/fastify", - "version": "0.0.0-test", - }, - "SECRET_KEY": "CLERK_SECRET_KEY", -} -`; diff --git a/packages/fastify/src/__tests__/__snapshots__/getAuth.test.ts.snap b/packages/fastify/src/__tests__/__snapshots__/getAuth.test.ts.snap index 0eb0696281d..1e69fff4e0b 100644 --- a/packages/fastify/src/__tests__/__snapshots__/getAuth.test.ts.snap +++ b/packages/fastify/src/__tests__/__snapshots__/getAuth.test.ts.snap @@ -13,17 +13,3 @@ For more info, check out the docs: https://clerk.com/docs, or come say hi in our discord server: https://clerk.com/discord ] `; - -exports[`getAuth(req) throws error if clerkPlugin is on registered 1`] = ` -"🔒 Clerk: The "clerkPlugin" should be registered before using the "getAuth". -Example: - -import { clerkPlugin } from '@clerk/fastify'; - -const server: FastifyInstance = Fastify({ logger: true }); -server.register(clerkPlugin); - -For more info, check out the docs: https://clerk.com/docs, -or come say hi in our discord server: https://clerk.com/discord -" -`; diff --git a/packages/fastify/src/__tests__/frontendApiProxy.test.ts b/packages/fastify/src/__tests__/frontendApiProxy.test.ts new file mode 100644 index 00000000000..b63751a7bfc --- /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: expect.stringContaining('/__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..932d07b098a 100644 --- a/packages/fastify/src/types.ts +++ b/packages/fastify/src/types.ts @@ -1,7 +1,45 @@ import type { ClerkOptions } from '@clerk/backend'; +import type { ShouldProxyFn } from '@clerk/shared/proxy'; export const ALLOWED_HOOKS = ['onRequest', 'preHandler'] as const; +/** + * Options for configuring Frontend API proxy in clerkPlugin + */ +export interface FrontendApiProxyOptions { + /** + * Enable proxy handling. Can be: + * - `true` - enable for all domains + * - `false` - disable for all domains + * - A function: (url: URL) => boolean - enable based on the request URL + */ + enabled: boolean | ShouldProxyFn; + /** + * The path prefix for proxy requests. + * + * @default '/__clerk' + */ + path?: string; +} + export type ClerkFastifyOptions = ClerkOptions & { hookName?: (typeof ALLOWED_HOOKS)[number]; + /** + * Configure Frontend API proxy handling. When set, requests to the proxy path + * will skip authentication, and the proxyUrl will be automatically derived + * for handshake redirects. + * + * @example + * // Enable with defaults (path: '/__clerk') + * clerkPlugin({ frontendApiProxy: { enabled: true } }) + * + * @example + * // Custom path + * clerkPlugin({ frontendApiProxy: { enabled: true, path: '/my-proxy' } }) + * + * @example + * // Disable proxy handling + * clerkPlugin({ frontendApiProxy: { enabled: false } }) + */ + frontendApiProxy?: FrontendApiProxyOptions; }; diff --git a/packages/fastify/src/utils.ts b/packages/fastify/src/utils.ts index 95ed4a08639..69acb033c9e 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 a real URL 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..e1cc7bb780e 100644 --- a/packages/fastify/src/withClerkMiddleware.ts +++ b/packages/fastify/src/withClerkMiddleware.ts @@ -1,19 +1,90 @@ 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); + 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(); + } + + if (!resolvedProxyUrl) { + const forwardedProto = fastifyRequest.headers['x-forwarded-proto']; + const protoHeader = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; + const protocol = (protoHeader || '').split(',')[0].trim() || 'https'; + + const forwardedHost = fastifyRequest.headers['x-forwarded-host']; + const hostHeader = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost; + const host = (hostHeader || '').split(',')[0].trim() || fastifyRequest.hostname || 'localhost'; + + resolvedProxyUrl = `${protocol}://${host}${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', });