Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fastify-proxy-support.md
Original file line number Diff line number Diff line change
@@ -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.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
`;
14 changes: 0 additions & 14 deletions packages/fastify/src/__tests__/__snapshots__/getAuth.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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
"
`;
180 changes: 180 additions & 0 deletions packages/fastify/src/__tests__/frontendApiProxy.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof clerkPlugin>[1],
path: string,
headers: Record<string, string> = {},
) {
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'),
}),
);
});
});
2 changes: 1 addition & 1 deletion packages/fastify/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
38 changes: 38 additions & 0 deletions packages/fastify/src/types.ts
Original file line number Diff line number Diff line change
@@ -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;
};
35 changes: 35 additions & 0 deletions packages/fastify/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FastifyRequest } from 'fastify';
import { Readable } from 'stream';

export const fastifyRequestToRequest = (req: FastifyRequest): Request => {
const headers = new Headers(
Expand Down Expand Up @@ -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,
});
};
Loading
Loading