diff --git a/.changeset/silent-fastify-handshakes.md b/.changeset/silent-fastify-handshakes.md index 3e26bf8c033..ae5e9f843a9 100644 --- a/.changeset/silent-fastify-handshakes.md +++ b/.changeset/silent-fastify-handshakes.md @@ -1,5 +1,5 @@ --- -'@clerk/fastify': patch +'@clerk/fastify': minor --- -Fixed `clerkPlugin()` to honor `publishableKey` and `secretKey` passed in plugin options when authenticating Fastify requests. The plugin now also exposes `request.clerk`, which uses the same plugin keys and resolves the correct Clerk API host for non-production publishable keys. +Add an `enableHandshake` option to `clerkPlugin()` (defaults to `true`). When set to `false`, the plugin skips the handshake flow and strips handshake cookies (`__clerk_handshake`, `__clerk_handshake_nonce`) and query params before authenticating the request. This is useful for pure API backends (e.g. a SPA calling a Fastify server) where the server cannot return `Set-Cookie` headers to the browser, which would otherwise cause stale handshake nonces to be reused and trigger repeated `404` errors from the Frontend API. diff --git a/packages/fastify/src/__tests__/withClerkMiddleware.test.ts b/packages/fastify/src/__tests__/withClerkMiddleware.test.ts index 46a80e25d49..3054c286b98 100644 --- a/packages/fastify/src/__tests__/withClerkMiddleware.test.ts +++ b/packages/fastify/src/__tests__/withClerkMiddleware.test.ts @@ -209,6 +209,122 @@ describe('withClerkMiddleware(options)', () => { }); }); + test('skips handshake redirect when enableHandshake is false', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + status: 'handshake', + headers: new Headers({ + location: 'https://fapi.example.com/v1/clients/handshake', + 'x-clerk-auth-status': 'handshake', + }), + toAuth: () => ({ + tokenType: 'session_token', + }), + }); + const fastify = Fastify(); + await fastify.register(clerkPlugin, { enableHandshake: false }); + + fastify.get('/', (request: FastifyRequest, reply: FastifyReply) => { + const auth = getAuth(request); + reply.send({ auth }); + }); + + const response = await fastify.inject({ + method: 'GET', + path: '/', + headers: { + cookie: '__clerk_handshake_nonce=deadbeef; __client_uat=1675692233', + }, + }); + + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual(JSON.stringify({ auth: { tokenType: 'session_token' } })); + }); + + test('still redirects for dev-browser-missing handshake even when enableHandshake is false', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + status: 'handshake', + reason: 'dev-browser-missing', + headers: new Headers({ + location: 'https://fapi.example.com/v1/clients/handshake', + 'x-clerk-auth-status': 'handshake', + 'x-clerk-auth-reason': 'dev-browser-missing', + }), + toAuth: () => ({ tokenType: 'session_token' }), + }); + const fastify = Fastify(); + await fastify.register(clerkPlugin, { enableHandshake: false }); + + fastify.get('/', (_request: FastifyRequest, reply: FastifyReply) => { + reply.send({}); + }); + + const response = await fastify.inject({ + method: 'GET', + path: '/', + headers: { cookie: '__client_uat=1675692233' }, + }); + + expect(response.statusCode).toEqual(307); + expect(response.headers.location).toEqual('https://fapi.example.com/v1/clients/handshake'); + }); + + test('still redirects for dev-browser-sync handshake even when enableHandshake is false', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + status: 'handshake', + reason: 'dev-browser-sync', + headers: new Headers({ + location: 'https://fapi.example.com/v1/clients/handshake', + 'x-clerk-auth-status': 'handshake', + 'x-clerk-auth-reason': 'dev-browser-sync', + }), + toAuth: () => ({ tokenType: 'session_token' }), + }); + const fastify = Fastify(); + await fastify.register(clerkPlugin, { enableHandshake: false }); + + fastify.get('/', (_request: FastifyRequest, reply: FastifyReply) => { + reply.send({}); + }); + + const response = await fastify.inject({ + method: 'GET', + path: '/', + headers: { cookie: '__client_uat=1675692233' }, + }); + + expect(response.statusCode).toEqual(307); + expect(response.headers.location).toEqual('https://fapi.example.com/v1/clients/handshake'); + }); + + test('strips handshake cookies and query params before authenticating when enableHandshake is false', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + headers: new Headers(), + toAuth: () => ({ tokenType: 'session_token' }), + }); + const fastify = Fastify(); + await fastify.register(clerkPlugin, { enableHandshake: false }); + + fastify.get('/', (_request: FastifyRequest, reply: FastifyReply) => { + reply.send({}); + }); + + await fastify.inject({ + method: 'GET', + path: '/?__clerk_handshake=token123&__clerk_handshake_nonce=nonce456&foo=bar', + headers: { + cookie: '__clerk_handshake=token123; __clerk_handshake_nonce=nonce456; __client_uat=1675692233', + }, + }); + + const [req] = authenticateRequestMock.mock.calls[0]; + expect(new URL(req.url).searchParams.has('__clerk_handshake')).toBe(false); + expect(new URL(req.url).searchParams.has('__clerk_handshake_nonce')).toBe(false); + expect(new URL(req.url).searchParams.get('foo')).toBe('bar'); + expect(req.headers.get('cookie')).not.toContain('__clerk_handshake='); + expect(req.headers.get('cookie')).not.toContain('__clerk_handshake_nonce='); + expect(req.headers.get('cookie')).toContain('__client_uat=1675692233'); + }); + test('exposes the runtime key clerk client instance on request.clerk', async () => { authenticateRequestMock.mockResolvedValueOnce({ headers: new Headers(), diff --git a/packages/fastify/src/types.ts b/packages/fastify/src/types.ts index 7335800f085..c1ed7e8128c 100644 --- a/packages/fastify/src/types.ts +++ b/packages/fastify/src/types.ts @@ -27,4 +27,12 @@ export interface FrontendApiProxyOptions { export type ClerkFastifyOptions = ClerkOptions & { hookName?: (typeof ALLOWED_HOOKS)[number]; frontendApiProxy?: FrontendApiProxyOptions; + /** + * Whether to enable the handshake flow for session verification. + * Disable this when using Clerk with a first-party API backend (e.g. a SPA calling + * a Fastify server) to prevent handshake nonce cookies set during OAuth callbacks + * from blocking authentication on subsequent API requests. + * @default true + */ + enableHandshake?: boolean; }; diff --git a/packages/fastify/src/withClerkMiddleware.ts b/packages/fastify/src/withClerkMiddleware.ts index 17751c0cf50..445e146d2ba 100644 --- a/packages/fastify/src/withClerkMiddleware.ts +++ b/packages/fastify/src/withClerkMiddleware.ts @@ -9,6 +9,30 @@ import * as constants from './constants'; import type { ClerkFastifyOptions } from './types'; import { fastifyRequestToRequest, requestToProxyRequest } from './utils'; +function stripHandshakeCookiesAndParams(req: Request, cookieNames: string[]): Request { + const url = new URL(req.url); + for (const name of cookieNames) { + url.searchParams.delete(name); + } + + const headers = new Headers(req.headers); + const cookieHeader = headers.get('cookie'); + if (cookieHeader) { + const filtered = cookieHeader + .split(';') + .map(c => c.trim()) + .filter(c => !cookieNames.some(name => c === name || c.startsWith(`${name}=`))) + .join('; '); + if (filtered) { + headers.set('cookie', filtered); + } else { + headers.delete('cookie'); + } + } + + return new Request(url.toString(), { method: req.method, headers }); +} + export const withClerkMiddleware = (options: ClerkFastifyOptions) => { const { hookName: _hookName, frontendApiProxy, ...clerkOptions } = options; const proxyPath = stripTrailingSlashes(frontendApiProxy?.path ?? DEFAULT_PROXY_PATH) || DEFAULT_PROXY_PATH; @@ -26,6 +50,7 @@ export const withClerkMiddleware = (options: ClerkFastifyOptions) => { userAgent: options.userAgent || `${constants.SDK_METADATA.name}@${constants.SDK_METADATA.version}`, sdkMetadata: options.sdkMetadata || constants.SDK_METADATA, }); + const enableHandshake = options.enableHandshake ?? true; return async (fastifyRequest: FastifyRequest, reply: FastifyReply) => { // Handle Frontend API proxy requests and auto-derive proxyUrl @@ -84,7 +109,11 @@ export const withClerkMiddleware = (options: ClerkFastifyOptions) => { } } - const req = fastifyRequestToRequest(fastifyRequest); + let req = fastifyRequestToRequest(fastifyRequest); + + if (!enableHandshake) { + req = stripHandshakeCookiesAndParams(req, [constants.Cookies.Handshake, constants.Cookies.HandshakeNonce]); + } const requestState = await clerkClient.authenticateRequest(req, { ...options, @@ -98,8 +127,12 @@ export const withClerkMiddleware = (options: ClerkFastifyOptions) => { const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { - return reply.code(307).send(); - } else if (requestState.status === AuthStatus.Handshake) { + const isDevBrowserHandshake = + requestState.reason === 'dev-browser-missing' || requestState.reason === 'dev-browser-sync'; + if (enableHandshake || isDevBrowserHandshake) { + return reply.code(307).send(); + } + } else if (enableHandshake && requestState.status === AuthStatus.Handshake) { throw new Error('Clerk: handshake status without redirect'); } diff --git a/scripts/renovate-config-generator.mjs b/scripts/renovate-config-generator.mjs index 5cd9ae09594..749e4f443f3 100644 --- a/scripts/renovate-config-generator.mjs +++ b/scripts/renovate-config-generator.mjs @@ -203,13 +203,7 @@ const renovateConfig = { 'integration/templates/**', 'packages/upgrade/src/__tests__/fixtures/**', ], - includePaths: [ - '.github/actions/**', - '.github/workflows/**', - 'package.json', - 'packages/**', - 'pnpm-workspace.yaml', - ], + includePaths: ['.github/actions/**', '.github/workflows/**', 'package.json', 'packages/**', 'pnpm-workspace.yaml'], major: { dependencyDashboardApproval: true }, minimumReleaseAge: '3 days', nvm: { enabled: false },