diff --git a/.changeset/express-forward-auth-options.md b/.changeset/express-forward-auth-options.md new file mode 100644 index 00000000000..c7e01d6f136 --- /dev/null +++ b/.changeset/express-forward-auth-options.md @@ -0,0 +1,5 @@ +--- +"@clerk/express": patch +--- + +Forward all `AuthenticateRequestOptions` and `VerifyTokenOptions` passed to `clerkMiddleware()` through to the backend `authenticateRequest()` call. Previously only a hand-picked subset was forwarded, so options like `organizationSyncOptions`, `skipJwksCache`, and `headerType` were accepted by the TypeScript types but silently ignored at runtime — the same class of bug that caused `clockSkewInMs` to be dropped. diff --git a/packages/express/src/__tests__/clerkMiddleware.test.ts b/packages/express/src/__tests__/clerkMiddleware.test.ts index f1c9bdbc9d9..20519465d5d 100644 --- a/packages/express/src/__tests__/clerkMiddleware.test.ts +++ b/packages/express/src/__tests__/clerkMiddleware.test.ts @@ -125,6 +125,84 @@ describe('clerkMiddleware', () => { ); }); + it('forwards arbitrary AuthenticateRequestOptions/VerifyTokenOptions to authenticateRequest', async () => { + const authenticateRequestMock = vi.fn().mockResolvedValue({}); + const clerkClient = { + authenticateRequest: authenticateRequestMock, + } as any; + + const organizationSyncOptions = { + organizationPatterns: ['/orgs/:slug'], + }; + + await authenticateRequest({ + clerkClient, + request: { + method: 'GET', + url: '/', + headers: { + host: 'example.com', + }, + } as Request, + options: { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_....', + clockSkewInMs: 12_345, + audience: 'https://api.example.com', + authorizedParties: ['https://example.com'], + jwtKey: 'jwt-key-value', + acceptsToken: 'session_token', + organizationSyncOptions, + skipJwksCache: true, + headerType: 'JWT', + } as any, + }); + + expect(authenticateRequestMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + audience: 'https://api.example.com', + authorizedParties: ['https://example.com'], + clockSkewInMs: 12_345, + jwtKey: 'jwt-key-value', + acceptsToken: 'session_token', + organizationSyncOptions, + skipJwksCache: true, + headerType: 'JWT', + }), + ); + }); + + it('does not forward middleware-only options (clerkClient, debug, frontendApiProxy) to authenticateRequest', async () => { + const authenticateRequestMock = vi.fn().mockResolvedValue({}); + const clerkClient = { + authenticateRequest: authenticateRequestMock, + } as any; + + await authenticateRequest({ + clerkClient, + request: { + method: 'GET', + url: '/', + headers: { + host: 'example.com', + }, + } as Request, + options: { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_....', + clerkClient, + debug: true, + frontendApiProxy: { enabled: true, path: '/__clerk' }, + }, + }); + + const forwarded = authenticateRequestMock.mock.calls[0][1]; + expect(forwarded).not.toHaveProperty('clerkClient'); + expect(forwarded).not.toHaveProperty('debug'); + expect(forwarded).not.toHaveProperty('frontendApiProxy'); + }); + it('throws error if clerkMiddleware is not executed before getAuth', async () => { const customMiddleware: RequestHandler = (request, response, next) => { const auth = getAuth(request); diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts index 1f8cd0f8ef1..bae475d9e3f 100644 --- a/packages/express/src/authenticateRequest.ts +++ b/packages/express/src/authenticateRequest.ts @@ -24,20 +24,36 @@ import { incomingMessageToRequest, loadApiEnv, loadClientEnv, requestToProxyRequ */ export const authenticateRequest = (opts: AuthenticateRequestParams) => { const { clerkClient, request, options } = opts; - const { jwtKey, authorizedParties, audience, acceptsToken, clockSkewInMs } = options || {}; + // Peel off middleware-only keys and the few options that need middleware-side + // resolution (env fallbacks, URL normalization). Everything else is spread + // straight through, so new AuthenticateRequestOptions/VerifyTokenOptions + // fields flow to the backend without another code change here. + const { + clerkClient: _clerkClient, + debug: _debug, + frontendApiProxy: _frontendApiProxy, + isSatellite: isSatelliteInput, + domain: domainInput, + signInUrl: signInUrlInput, + proxyUrl: proxyUrlInput, + secretKey: secretKeyInput, + machineSecretKey: machineSecretKeyInput, + publishableKey: publishableKeyInput, + ...restOptions + } = options || {}; const clerkRequest = createClerkRequest(incomingMessageToRequest(request)); const env = { ...loadApiEnv(), ...loadClientEnv() }; - const secretKey = options?.secretKey || env.secretKey; - const machineSecretKey = options?.machineSecretKey || env.machineSecretKey; - const publishableKey = options?.publishableKey || env.publishableKey; + const secretKey = secretKeyInput || env.secretKey; + const machineSecretKey = machineSecretKeyInput || env.machineSecretKey; + const publishableKey = publishableKeyInput || env.publishableKey; - const isSatellite = handleValueOrFn(options?.isSatellite, clerkRequest.clerkUrl, env.isSatellite); - const domain = handleValueOrFn(options?.domain, clerkRequest.clerkUrl) || env.domain; - const signInUrl = options?.signInUrl || env.signInUrl; + const isSatellite = handleValueOrFn(isSatelliteInput, clerkRequest.clerkUrl, env.isSatellite); + const domain = handleValueOrFn(domainInput, clerkRequest.clerkUrl) || env.domain; + const signInUrl = signInUrlInput || env.signInUrl; const proxyUrl = absoluteProxyUrl( - handleValueOrFn(options?.proxyUrl, clerkRequest.clerkUrl, env.proxyUrl), + handleValueOrFn(proxyUrlInput, clerkRequest.clerkUrl, env.proxyUrl), clerkRequest.clerkUrl.toString(), ); @@ -50,18 +66,14 @@ export const authenticateRequest = (opts: AuthenticateRequestParams) => { } return clerkClient.authenticateRequest(clerkRequest, { - audience, + ...restOptions, secretKey, machineSecretKey, publishableKey, - jwtKey, - clockSkewInMs, - authorizedParties, proxyUrl, isSatellite, domain, signInUrl, - acceptsToken, }); };