diff --git a/.changeset/backend-internal-enable-handshake.md b/.changeset/backend-internal-enable-handshake.md new file mode 100644 index 00000000000..9a44821ef13 --- /dev/null +++ b/.changeset/backend-internal-enable-handshake.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Add internal `__internal_resolveHandshakeOnlyForNavigation` option to `authenticateRequest()`. diff --git a/.changeset/fastify-delegate-handshake-opt-out.md b/.changeset/fastify-delegate-handshake-opt-out.md new file mode 100644 index 00000000000..8bd0c21cebb --- /dev/null +++ b/.changeset/fastify-delegate-handshake-opt-out.md @@ -0,0 +1,5 @@ +--- +'@clerk/fastify': patch +--- + +`__internal_enableHandshake` now delegates to `@clerk/backend`. diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 947507a2d99..a9bb9da53fc 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1,6 +1,7 @@ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; +import type { ApiClient } from '../../api'; import { MachineTokenVerificationErrorCode, TokenVerificationErrorReason } from '../../errors'; import { mockExpiredJwt, @@ -21,6 +22,7 @@ import { signJwt } from '../../jwt/signJwt'; import { server } from '../../mock-server'; import type { AuthReason } from '../authStatus'; import { AuthErrorReason, AuthStatus } from '../authStatus'; +import { HandshakeService } from '../handshake'; import { JWT_CATEGORY_JWT_TEMPLATE } from '../jwtCategories'; import { OrganizationMatcher } from '../organizationMatcher'; import { authenticateRequest, RefreshTokenErrorReason } from '../request'; @@ -2467,4 +2469,191 @@ describe('tokens.authenticateRequest(options)', () => { expect(requestState).toBeSignedOut({ reason: AuthErrorReason.SessionTokenIATBeforeClientUAT }); }); }); + + describe('__internal_resolveHandshakeOnlyForNavigation', () => { + const fetchHeaders = { 'sec-fetch-dest': 'empty', accept: '*/*' }; + + const mockOptionsWithHandshakePayload = (overrides: Partial = {}) => { + const getHandshakePayload = vi.fn().mockResolvedValue({ directives: [`__session=${mockJwt}; Path=/`] }); + const options = mockOptions({ + publishableKey: PK_LIVE, + apiClient: { clients: { getHandshakePayload } } as unknown as ApiClient, + ...overrides, + }); + return { options, getHandshakePayload }; + }; + + beforeEach(() => { + server.use( + http.get('https://api.clerk.test/v1/jwks', () => { + return HttpResponse.json(mockJwks); + }), + ); + }); + + test('skips the payload exchange on a fetch request with a stale nonce and authenticates from the session cookie', async () => { + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload({ + __internal_resolveHandshakeOnlyForNavigation: true, + }); + + const requestState = await authenticateRequest( + mockRequestWithCookies(fetchHeaders, { + __clerk_handshake_nonce: 'stale', + __client_uat: '12345', + __session: mockJwt, + }), + options, + ); + + expect(getHandshakePayload).not.toHaveBeenCalled(); + expect(requestState).toBeSignedIn(); + expect(requestState.headers.get('location')).toBeNull(); + }); + + test('skips the payload exchange on a POST request with a stale nonce', async () => { + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload({ + __internal_resolveHandshakeOnlyForNavigation: true, + }); + + const requestState = await authenticateRequest( + new Request('http://clerk.com/path', { + method: 'POST', + headers: { + ...defaultHeaders, + cookie: `__clerk_handshake_nonce=stale;__client_uat=12345;__session=${mockJwt}`, + }, + }), + options, + ); + + expect(getHandshakePayload).not.toHaveBeenCalled(); + expect(requestState).toBeSignedIn(); + }); + + test('returns signed out without a payload exchange on a fetch request with a stale nonce and no session', async () => { + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload({ + __internal_resolveHandshakeOnlyForNavigation: true, + }); + + const requestState = await authenticateRequest( + mockRequestWithCookies(fetchHeaders, { __clerk_handshake_nonce: 'stale', __client_uat: '12345' }), + options, + ); + + expect(getHandshakePayload).not.toHaveBeenCalled(); + expect(requestState).toBeSignedOut({ reason: AuthErrorReason.ClientUATWithoutSessionToken }); + expect(requestState.headers.get('location')).toBeNull(); + }); + + test('still exchanges the payload on a navigation request with a nonce', async () => { + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload({ + __internal_resolveHandshakeOnlyForNavigation: true, + }); + + const requestState = await authenticateRequest( + mockRequestWithCookies({}, { __clerk_handshake_nonce: 'fresh', __client_uat: '12345' }), + options, + ); + + expect(getHandshakePayload).toHaveBeenCalledWith({ nonce: 'fresh' }); + expect(requestState).toBeSignedIn(); + }); + + test('still redirects a development navigation request to the dev browser handshake', async () => { + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload({ + __internal_resolveHandshakeOnlyForNavigation: true, + publishableKey: PK_TEST, + secretKey: 'test_deadbeef', + }); + + const requestState = await authenticateRequest(mockRequestWithCookies(), options); + + expect(getHandshakePayload).not.toHaveBeenCalled(); + expect(requestState).toMatchHandshake({ reason: AuthErrorReason.DevBrowserMissing }); + }); + + test('still resolves the nonce on a development navigation request returning from the handshake', async () => { + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload({ + __internal_resolveHandshakeOnlyForNavigation: true, + publishableKey: PK_TEST, + secretKey: 'test_deadbeef', + }); + + const requestState = await authenticateRequest( + mockRequestWithCookies({}, { __clerk_handshake_nonce: 'fresh' }), + options, + ); + + expect(getHandshakePayload).toHaveBeenCalledTimes(1); + expect(requestState).toBeSignedIn(); + }); + + test('ignores a stale cookie-transport handshake token on a fetch request without logging', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { options } = mockOptionsWithHandshakePayload({ __internal_resolveHandshakeOnlyForNavigation: true }); + + const requestState = await authenticateRequest( + mockRequestWithCookies(fetchHeaders, { + __clerk_handshake: 'not-a-jwt', + __client_uat: '12345', + __session: mockJwt, + }), + options, + ); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(requestState).toBeSignedIn(); + }); + + test('by default logs a resolution error for a stale cookie-transport handshake token on a fetch request', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { options } = mockOptionsWithHandshakePayload(); + + const requestState = await authenticateRequest( + mockRequestWithCookies(fetchHeaders, { + __clerk_handshake: 'not-a-jwt', + __client_uat: '12345', + __session: mockJwt, + }), + options, + ); + + expect(errorSpy).toHaveBeenCalledWith('Clerk: unable to resolve handshake:', expect.anything()); + expect(requestState).toBeSignedIn(); + }); + + test.each([ + ['omitted', {}], + ['false', { __internal_resolveHandshakeOnlyForNavigation: false }], + ])( + 'when the option is %s the eligibility check is never consulted and the payload is exchanged', + async (_, flag) => { + const eligibilitySpy = vi.spyOn(HandshakeService.prototype, 'isRequestEligibleForHandshake'); + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload(flag); + + await authenticateRequest( + mockRequestWithCookies(fetchHeaders, { __clerk_handshake_nonce: 'fresh', __client_uat: '12345' }), + options, + ); + + expect(eligibilitySpy).not.toHaveBeenCalled(); + expect(getHandshakePayload).toHaveBeenCalledTimes(1); + }, + ); + + test('only an explicit true consults the eligibility check', async () => { + const eligibilitySpy = vi.spyOn(HandshakeService.prototype, 'isRequestEligibleForHandshake'); + const { options, getHandshakePayload } = mockOptionsWithHandshakePayload({ + __internal_resolveHandshakeOnlyForNavigation: true, + }); + + await authenticateRequest( + mockRequestWithCookies(fetchHeaders, { __clerk_handshake_nonce: 'fresh', __client_uat: '12345' }), + options, + ); + + expect(eligibilitySpy).toHaveBeenCalled(); + expect(getHandshakePayload).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 07f16f0a97e..5a22e0bd200 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -461,8 +461,14 @@ export const authenticateRequest: AuthenticateRequest = (async ( /** * If we have a handshakeToken, resolve the handshake and attempt to return a definitive signed in or signed out state. + * With `__internal_resolveHandshakeOnlyForNavigation`, requests that are not eligible for a handshake redirect + * skip this, so a stale handshake cookie does not trigger a failing payload exchange. */ - if (authenticateContext.handshakeNonce || authenticateContext.handshakeToken) { + const hasHandshakeToken = authenticateContext.handshakeNonce || authenticateContext.handshakeToken; + const canResolveHandshake = + !authenticateContext.__internal_resolveHandshakeOnlyForNavigation || + handshakeService.isRequestEligibleForHandshake(); + if (hasHandshakeToken && canResolveHandshake) { try { return await handshakeService.resolveHandshake(); } catch (error) { diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index 823503a4aba..04ebcebccb2 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -86,6 +86,17 @@ export type AuthenticateRequestOptions = { * @default false */ satelliteAutoSync?: boolean; + /** + * When `true`, handshake payload resolution only runs for requests that are eligible for a + * handshake redirect. Requests that are not eligible ignore any handshake cookie or query param, + * so a stale handshake nonce no longer triggers a failing Backend API call on every request. + * Eligible requests still resolve and still redirect, so development instances keep working. + * Intended for API-only backends that cannot return `Set-Cookie` headers to the browser. + * + * @internal + * @default false + */ + __internal_resolveHandshakeOnlyForNavigation?: boolean; } & VerifyTokenOptions; /** diff --git a/packages/fastify/src/__tests__/withClerkMiddleware.handshake.test.ts b/packages/fastify/src/__tests__/withClerkMiddleware.handshake.test.ts new file mode 100644 index 00000000000..67929abeb4b --- /dev/null +++ b/packages/fastify/src/__tests__/withClerkMiddleware.handshake.test.ts @@ -0,0 +1,127 @@ +import type { AddressInfo } from 'node:net'; + +import Fastify from 'fastify'; +import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest'; + +import { clerkPlugin } from '../index'; +import type { ClerkFastifyOptions } from '../types'; + +// Runs the real @clerk/backend against a local stand-in for the Backend API. +const PK_LIVE = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; +const PK_TEST = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; + +const payloadRequests: string[] = []; +const fakeBackendApi = Fastify(); +fakeBackendApi.get('/v1/clients/handshake_payload', (request, reply) => { + payloadRequests.push(request.url); + reply.code(404).send({ errors: [{ code: 'resource_not_found', message: 'not found' }] }); +}); + +let apiUrl = ''; + +beforeAll(async () => { + await fakeBackendApi.listen({ port: 0, host: '127.0.0.1' }); + apiUrl = `http://127.0.0.1:${(fakeBackendApi.server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await fakeBackendApi.close(); +}); + +afterEach(() => { + payloadRequests.length = 0; + vi.restoreAllMocks(); +}); + +const buildApp = async (pluginOptions: Partial) => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const app = Fastify(); + await app.register(clerkPlugin, { apiUrl, ...pluginOptions }); + app.get('/api/me', (_request, reply) => { + reply.send({ + status: reply.getHeader('x-clerk-auth-status'), + reason: reply.getHeader('x-clerk-auth-reason'), + }); + }); + return app; +}; + +const production = { secretKey: 'sk_live_deadbeef', publishableKey: PK_LIVE }; +const development = { secretKey: 'sk_test_deadbeef', publishableKey: PK_TEST }; + +const fetchHeaders = { 'sec-fetch-dest': 'empty', accept: '*/*' }; +const navigationHeaders = { 'sec-fetch-dest': 'document', accept: 'text/html' }; + +describe('clerkPlugin handshake handling (real @clerk/backend)', () => { + test('by default a stale nonce on a fetch request is exchanged with the Backend API', async () => { + const app = await buildApp(production); + + const response = await app.inject({ + method: 'GET', + url: '/api/me', + headers: { ...fetchHeaders, cookie: '__clerk_handshake_nonce=stale; __client_uat=12345' }, + }); + + expect(response.statusCode).toBe(200); + expect(payloadRequests).toHaveLength(1); + expect(response.json()).toEqual({ status: 'signed-out', reason: 'session-token-missing' }); + }); + + test('with __internal_enableHandshake: false a stale nonce on a fetch request is ignored', async () => { + const app = await buildApp({ ...production, __internal_enableHandshake: false }); + + const response = await app.inject({ + method: 'GET', + url: '/api/me', + headers: { ...fetchHeaders, cookie: '__clerk_handshake_nonce=stale; __client_uat=12345' }, + }); + + expect(response.statusCode).toBe(200); + expect(payloadRequests).toHaveLength(0); + expect(response.json()).toEqual({ status: 'signed-out', reason: 'client-uat-but-no-session-token' }); + }); + + test('with __internal_enableHandshake: false a stale nonce on a POST request is ignored', async () => { + const app = await buildApp({ ...production, __internal_enableHandshake: false }); + app.post('/api/submit', (_request, reply) => { + reply.send({ status: reply.getHeader('x-clerk-auth-status') }); + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/submit', + headers: { ...navigationHeaders, cookie: '__clerk_handshake_nonce=stale; __client_uat=12345' }, + }); + + expect(response.statusCode).toBe(200); + expect(payloadRequests).toHaveLength(0); + expect(response.json()).toEqual({ status: 'signed-out' }); + }); + + test('with __internal_enableHandshake: false a development navigation still redirects to the dev browser handshake', async () => { + const app = await buildApp({ ...development, __internal_enableHandshake: false }); + + const response = await app.inject({ method: 'GET', url: '/api/me', headers: navigationHeaders }); + + expect(response.statusCode).toBe(307); + expect(response.headers.location).toContain('/v1/client/handshake'); + expect(response.headers['x-clerk-auth-reason']).toBe('dev-browser-missing'); + expect(payloadRequests).toHaveLength(0); + }); + + test('with __internal_enableHandshake: false a development navigation returning from the handshake still resolves the nonce', async () => { + const app = await buildApp({ ...development, __internal_enableHandshake: false }); + + const response = await app.inject({ + method: 'GET', + url: '/api/me?__clerk_handshake_nonce=fresh', + headers: navigationHeaders, + }); + + expect(payloadRequests).toHaveLength(1); + expect(payloadRequests[0]).toContain('nonce=fresh'); + // Development resolution redirects to the same URL with the handshake params removed. + expect(response.statusCode).toBe(307); + expect(response.headers.location).not.toContain('__clerk_handshake_nonce'); + }); +}); diff --git a/packages/fastify/src/__tests__/withClerkMiddleware.test.ts b/packages/fastify/src/__tests__/withClerkMiddleware.test.ts index 0eb86d41560..e9103039be1 100644 --- a/packages/fastify/src/__tests__/withClerkMiddleware.test.ts +++ b/packages/fastify/src/__tests__/withClerkMiddleware.test.ts @@ -315,100 +315,7 @@ describe('withClerkMiddleware(options)', () => { ); }); - test('skips handshake redirect when __internal_enableHandshake is false', async () => { - authenticateRequestMock.mockResolvedValueOnce({ - status: 'handshake', - reason: 'session-token-expired', - headers: new Headers({ - location: 'https://fapi.example.com/v1/clients/handshake', - 'x-clerk-auth-status': 'handshake', - 'cache-control': 'no-store', - }), - toAuth: () => ({ tokenType: 'session_token' }), - }); - const fastify = Fastify(); - await fastify.register(clerkPlugin, { __internal_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.headers.location).toBeUndefined(); - expect(response.headers['cache-control']).toBeUndefined(); - expect(response.body).toEqual(JSON.stringify({ auth: { tokenType: 'session_token' } })); - }); - - test('falls back to a signed-out auth object when a skipped handshake state has a null auth', async () => { - authenticateRequestMock.mockResolvedValueOnce({ - status: 'handshake', - reason: 'session-token-expired', - headers: new Headers({ - location: 'https://fapi.example.com/v1/clients/handshake', - 'x-clerk-auth-status': 'handshake', - }), - toAuth: () => null, - }); - const fastify = Fastify(); - await fastify.register(clerkPlugin, { __internal_enableHandshake: false }); - - fastify.get('/', (request: FastifyRequest, reply: FastifyReply) => { - const auth = getAuth(request); - reply.send({ userId: auth.userId, isAuthenticated: auth.isAuthenticated }); - }); - - const response = await fastify.inject({ - method: 'GET', - path: '/', - headers: { cookie: '__client_uat=1675692233' }, - }); - - expect(response.statusCode).toEqual(200); - expect(response.headers.location).toBeUndefined(); - expect(response.body).toEqual(JSON.stringify({ userId: null, isAuthenticated: false })); - }); - - test.each(['dev-browser-missing', 'dev-browser-sync'])( - 'still redirects for %s handshake even when __internal_enableHandshake is false', - async reason => { - authenticateRequestMock.mockResolvedValueOnce({ - status: 'handshake', - reason, - headers: new Headers({ - location: 'https://fapi.example.com/v1/clients/handshake', - 'x-clerk-auth-status': 'handshake', - 'x-clerk-auth-reason': reason, - }), - toAuth: () => null, - }); - const fastify = Fastify(); - await fastify.register(clerkPlugin, { __internal_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 __internal_enableHandshake is false', async () => { + test('asks @clerk/backend to resolve handshakes only for navigation when __internal_enableHandshake is false', async () => { authenticateRequestMock.mockResolvedValueOnce({ headers: new Headers(), toAuth: () => ({ tokenType: 'session_token' }), @@ -422,22 +329,17 @@ describe('withClerkMiddleware(options)', () => { 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', - }, + path: '/?__clerk_handshake_nonce=nonce456', + headers: { cookie: '__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'); + const [req, options] = authenticateRequestMock.mock.calls[0]; + expect(options).toEqual(expect.objectContaining({ __internal_resolveHandshakeOnlyForNavigation: true })); + expect(options).not.toHaveProperty('__internal_enableHandshake'); + expect(req.headers.get('cookie')).toContain('__clerk_handshake_nonce=nonce456'); }); - test('does not strip handshake cookies or query params by default', async () => { + test('leaves handshake resolution enabled by default', async () => { authenticateRequestMock.mockResolvedValueOnce({ headers: new Headers(), toAuth: () => ({ tokenType: 'session_token' }), @@ -449,16 +351,9 @@ describe('withClerkMiddleware(options)', () => { reply.send({}); }); - await fastify.inject({ - method: 'GET', - path: '/?__clerk_handshake=token123', - headers: { - cookie: '__clerk_handshake_nonce=nonce456; __client_uat=1675692233', - }, - }); + await fastify.inject({ method: 'GET', path: '/' }); - const [req] = authenticateRequestMock.mock.calls[0]; - expect(new URL(req.url).searchParams.get('__clerk_handshake')).toBe('token123'); - expect(req.headers.get('cookie')).toContain('__clerk_handshake_nonce=nonce456'); + const [, options] = authenticateRequestMock.mock.calls[0]; + expect(options).toEqual(expect.objectContaining({ __internal_resolveHandshakeOnlyForNavigation: false })); }); }); diff --git a/packages/fastify/src/types.ts b/packages/fastify/src/types.ts index 2b02c7eaf48..c1296c98bde 100644 --- a/packages/fastify/src/types.ts +++ b/packages/fastify/src/types.ts @@ -30,13 +30,11 @@ export type ClerkFastifyOptions = ClerkOptions & { /** * Whether to enable the handshake flow for session verification. * - * When set to `false`, the plugin strips handshake cookies (`__clerk_handshake`, - * `__clerk_handshake_nonce`) and query params before authenticating the request, and - * skips handshake redirects (except dev-browser handshakes, which development - * instances require). Intended for pure API backends (e.g. a SPA calling a Fastify - * server) where the server cannot deliver `Set-Cookie` headers back to the browser, - * so stale handshake nonces would otherwise be replayed and trigger repeated `404` - * errors from the Frontend API. + * When set to `false`, handshake cookies and query params are ignored on requests that + * are not eligible for a handshake redirect. Intended for pure API backends (e.g. a SPA + * calling a Fastify server) where the server cannot deliver `Set-Cookie` headers back to + * the browser, so stale handshake nonces would otherwise be replayed and trigger repeated + * `404` errors from the Frontend API. * * @internal * @default true diff --git a/packages/fastify/src/utils.ts b/packages/fastify/src/utils.ts index 3754add80b2..1b36da0ef9b 100644 --- a/packages/fastify/src/utils.ts +++ b/packages/fastify/src/utils.ts @@ -61,33 +61,3 @@ export const requestToProxyRequest = (req: FastifyRequest): Request => { duplex: hasBody ? 'half' : undefined, }); }; - -/** - * Removes handshake artifacts from a request before authentication. Handshake cookies and - * query params share the same names (`QueryParameters` aliases `Cookies` in `@clerk/backend`), - * so one list covers both. - */ -export const stripHandshakeCookiesAndParams = (req: Request, names: string[]): Request => { - const url = new URL(req.url); - for (const name of names) { - 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 => !names.some(name => c === name || c.startsWith(`${name}=`))) - .join('; '); - if (filtered) { - headers.set('cookie', filtered); - } else { - headers.delete('cookie'); - } - } - - // The body is dropped; this request is only passed to `authenticateRequest`, which never reads it. - return new Request(url.toString(), { method: req.method, headers }); -}; diff --git a/packages/fastify/src/withClerkMiddleware.ts b/packages/fastify/src/withClerkMiddleware.ts index 20b177e76a9..a2c9c557762 100644 --- a/packages/fastify/src/withClerkMiddleware.ts +++ b/packages/fastify/src/withClerkMiddleware.ts @@ -1,5 +1,5 @@ import { createClerkClient } from '@clerk/backend'; -import { AuthStatus, signedOutAuthObject } from '@clerk/backend/internal'; +import { AuthStatus } from '@clerk/backend/internal'; import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, stripTrailingSlashes } from '@clerk/backend/proxy'; import { apiUrlFromPublishableKey } from '@clerk/shared/apiUrlFromPublishableKey'; import type { FastifyReply, FastifyRequest } from 'fastify'; @@ -7,11 +7,10 @@ import { Readable } from 'stream'; import * as constants from './constants'; import type { ClerkFastifyOptions } from './types'; -import { fastifyRequestToRequest, requestToProxyRequest, stripHandshakeCookiesAndParams } from './utils'; +import { fastifyRequestToRequest, requestToProxyRequest } from './utils'; export const withClerkMiddleware = (options: ClerkFastifyOptions) => { const { hookName: _hookName, frontendApiProxy, __internal_enableHandshake, ...clerkOptions } = options; - const enableHandshake = __internal_enableHandshake ?? true; const proxyPath = stripTrailingSlashes(frontendApiProxy?.path ?? DEFAULT_PROXY_PATH) || DEFAULT_PROXY_PATH; const publishableKey = options.publishableKey || constants.PUBLISHABLE_KEY; const secretKey = options.secretKey || constants.SECRET_KEY; @@ -103,38 +102,26 @@ export const withClerkMiddleware = (options: ClerkFastifyOptions) => { return reply.code(400).send(); } - if (!enableHandshake) { - req = stripHandshakeCookiesAndParams(req, [constants.Cookies.Handshake, constants.Cookies.HandshakeNonce]); - } - const requestState = await clerkClient.authenticateRequest(req, { ...clerkOptions, secretKey, publishableKey, proxyUrl: resolvedProxyUrl, acceptsToken: 'any', + __internal_resolveHandshakeOnlyForNavigation: __internal_enableHandshake === false, }); requestState.headers.forEach((value, key) => reply.header(key, value)); const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { - // Development instances cannot establish auth state without the dev browser handshake. - const isDevBrowserHandshake = - requestState.reason === 'dev-browser-missing' || requestState.reason === 'dev-browser-sync'; - if (enableHandshake || isDevBrowserHandshake) { - return reply.code(307).send(); - } - reply.removeHeader(constants.Headers.Location); - reply.removeHeader(constants.Headers.CacheControl); - } else if (enableHandshake && requestState.status === AuthStatus.Handshake) { + return reply.code(307).send(); + } else if (requestState.status === AuthStatus.Handshake) { throw new Error('Clerk: handshake status without redirect'); } - // A skipped handshake redirect leaves a handshake state whose toAuth() is null. // @ts-expect-error Inject auth so getAuth can read it - fastifyRequest.auth = - requestState.toAuth() ?? signedOutAuthObject({ reason: requestState.reason, message: requestState.message }); + fastifyRequest.auth = requestState.toAuth(); fastifyRequest.clerk = clerkClient; }; };