Skip to content

Commit da11557

Browse files
authored
fix(express): forward all auth options to authenticateRequest (#8370)
1 parent 6408ab6 commit da11557

3 files changed

Lines changed: 237 additions & 15 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@clerk/express": patch
3+
---
4+
5+
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.
6+
7+
Additionally, when `apiUrl` or `apiVersion` are passed to `clerkMiddleware()` and no custom `clerkClient` is supplied, the middleware now builds a per-middleware `ClerkClient` configured with those values instead of using the env-only default singleton. This is required because `@clerk/backend` pins `apiUrl`/`apiVersion` at client construction time and ignores runtime overrides on `authenticateRequest()`. Passing your own `clerkClient` continues to take precedence.

packages/express/src/__tests__/clerkMiddleware.test.ts

Lines changed: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type * as ClerkBackend from '@clerk/backend';
12
import type { Request, RequestHandler, Response } from 'express';
23
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
34

@@ -12,7 +13,19 @@ vi.mock('@clerk/backend/proxy', async () => {
1213
};
1314
});
1415

15-
import { authenticateRequest } from '../authenticateRequest';
16+
const { mockCreateClerkClient } = vi.hoisted(() => ({
17+
mockCreateClerkClient: vi.fn(),
18+
}));
19+
vi.mock('@clerk/backend', async () => {
20+
const actual = await vi.importActual<typeof ClerkBackend>('@clerk/backend');
21+
mockCreateClerkClient.mockImplementation(actual.createClerkClient);
22+
return {
23+
...actual,
24+
createClerkClient: mockCreateClerkClient,
25+
};
26+
});
27+
28+
import { authenticateAndDecorateRequest, authenticateRequest } from '../authenticateRequest';
1629
import { clerkMiddleware } from '../clerkMiddleware';
1730
import { getAuth } from '../getAuth';
1831
import { assertNoDebugHeaders, assertSignedOutDebugHeaders, runMiddleware, runMiddlewareOnPath } from './helpers';
@@ -125,6 +138,176 @@ describe('clerkMiddleware', () => {
125138
);
126139
});
127140

141+
it('forwards arbitrary AuthenticateRequestOptions/VerifyTokenOptions to authenticateRequest', async () => {
142+
const authenticateRequestMock = vi.fn().mockResolvedValue({});
143+
const clerkClient = {
144+
authenticateRequest: authenticateRequestMock,
145+
} as any;
146+
147+
const organizationSyncOptions = {
148+
organizationPatterns: ['/orgs/:slug'],
149+
};
150+
151+
await authenticateRequest({
152+
clerkClient,
153+
request: {
154+
method: 'GET',
155+
url: '/',
156+
headers: {
157+
host: 'example.com',
158+
},
159+
} as Request,
160+
options: {
161+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
162+
secretKey: 'sk_test_....',
163+
clockSkewInMs: 12_345,
164+
audience: 'https://api.example.com',
165+
authorizedParties: ['https://example.com'],
166+
jwtKey: 'jwt-key-value',
167+
acceptsToken: 'session_token',
168+
organizationSyncOptions,
169+
skipJwksCache: true,
170+
headerType: 'JWT',
171+
} as any,
172+
});
173+
174+
expect(authenticateRequestMock).toHaveBeenCalledWith(
175+
expect.any(Object),
176+
expect.objectContaining({
177+
audience: 'https://api.example.com',
178+
authorizedParties: ['https://example.com'],
179+
clockSkewInMs: 12_345,
180+
jwtKey: 'jwt-key-value',
181+
acceptsToken: 'session_token',
182+
organizationSyncOptions,
183+
skipJwksCache: true,
184+
headerType: 'JWT',
185+
}),
186+
);
187+
});
188+
189+
it('does not forward middleware-only options (clerkClient, debug, frontendApiProxy) to authenticateRequest', async () => {
190+
const authenticateRequestMock = vi.fn().mockResolvedValue({});
191+
const clerkClient = {
192+
authenticateRequest: authenticateRequestMock,
193+
} as any;
194+
195+
await authenticateRequest({
196+
clerkClient,
197+
request: {
198+
method: 'GET',
199+
url: '/',
200+
headers: {
201+
host: 'example.com',
202+
},
203+
} as Request,
204+
options: {
205+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
206+
secretKey: 'sk_test_....',
207+
clerkClient,
208+
debug: true,
209+
frontendApiProxy: { enabled: true, path: '/__clerk' },
210+
},
211+
});
212+
213+
const forwarded = authenticateRequestMock.mock.calls[0][1];
214+
expect(forwarded).not.toHaveProperty('clerkClient');
215+
expect(forwarded).not.toHaveProperty('debug');
216+
expect(forwarded).not.toHaveProperty('frontendApiProxy');
217+
});
218+
219+
describe('apiUrl/apiVersion default-client construction', () => {
220+
beforeEach(() => {
221+
mockCreateClerkClient.mockClear();
222+
});
223+
224+
it('builds a per-middleware ClerkClient with apiUrl when no custom clerkClient is supplied', () => {
225+
authenticateAndDecorateRequest({
226+
apiUrl: 'https://api.example.test',
227+
secretKey: 'sk_test_....',
228+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
229+
});
230+
231+
expect(mockCreateClerkClient).toHaveBeenCalledWith(
232+
expect.objectContaining({ apiUrl: 'https://api.example.test' }),
233+
);
234+
});
235+
236+
it('builds a per-middleware ClerkClient with apiVersion when no custom clerkClient is supplied', () => {
237+
authenticateAndDecorateRequest({
238+
apiVersion: 'v2',
239+
secretKey: 'sk_test_....',
240+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
241+
});
242+
243+
expect(mockCreateClerkClient).toHaveBeenCalledWith(expect.objectContaining({ apiVersion: 'v2' }));
244+
});
245+
246+
it('does not call createClerkClient at construction when apiUrl/apiVersion are not set', () => {
247+
authenticateAndDecorateRequest({ secretKey: 'sk_test_....' });
248+
249+
expect(mockCreateClerkClient).not.toHaveBeenCalled();
250+
});
251+
252+
it('does not build a per-middleware client when the caller supplies their own clerkClient', () => {
253+
const customClient = { authenticateRequest: vi.fn() } as any;
254+
255+
authenticateAndDecorateRequest({
256+
apiUrl: 'https://api.example.test',
257+
apiVersion: 'v2',
258+
clerkClient: customClient,
259+
});
260+
261+
expect(mockCreateClerkClient).not.toHaveBeenCalled();
262+
});
263+
264+
it('routes outbound API traffic to the apiUrl override', async () => {
265+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
266+
new Response('{"data":[],"total_count":0}', {
267+
status: 200,
268+
headers: { 'content-type': 'application/json' },
269+
}),
270+
);
271+
272+
authenticateAndDecorateRequest({
273+
apiUrl: 'https://api.example.test',
274+
secretKey: 'sk_test_....',
275+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
276+
});
277+
278+
const client = mockCreateClerkClient.mock.results[0].value;
279+
await client.users.getUserList().catch(() => undefined);
280+
281+
const calledUrls = fetchSpy.mock.calls.map(call => {
282+
const input = call[0];
283+
if (typeof input === 'string') {
284+
return input;
285+
}
286+
if (input instanceof URL) {
287+
return input.href;
288+
}
289+
return input.url;
290+
});
291+
expect(calledUrls.some(url => new URL(url).origin === 'https://api.example.test')).toBe(true);
292+
293+
fetchSpy.mockRestore();
294+
});
295+
296+
it('callback form: builds a per-middleware ClerkClient when the callback returns apiUrl', async () => {
297+
await runMiddleware(
298+
clerkMiddleware(() => ({
299+
apiUrl: 'https://api.example.test',
300+
secretKey: 'sk_test_....',
301+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
302+
})),
303+
).expect(200);
304+
305+
expect(mockCreateClerkClient).toHaveBeenCalledWith(
306+
expect.objectContaining({ apiUrl: 'https://api.example.test' }),
307+
);
308+
});
309+
});
310+
128311
it('throws error if clerkMiddleware is not executed before getAuth', async () => {
129312
const customMiddleware: RequestHandler = (request, response, next) => {
130313
const auth = getAuth(request);

packages/express/src/authenticateRequest.ts

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createClerkClient } from '@clerk/backend';
12
import type { RequestState } from '@clerk/backend/internal';
23
import { AuthStatus, createClerkRequest } from '@clerk/backend/internal';
34
import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, stripTrailingSlashes } from '@clerk/backend/proxy';
@@ -24,20 +25,36 @@ import { incomingMessageToRequest, loadApiEnv, loadClientEnv, requestToProxyRequ
2425
*/
2526
export const authenticateRequest = (opts: AuthenticateRequestParams) => {
2627
const { clerkClient, request, options } = opts;
27-
const { jwtKey, authorizedParties, audience, acceptsToken, clockSkewInMs } = options || {};
28+
// Peel off middleware-only keys and the few options that need middleware-side
29+
// resolution (env fallbacks, URL normalization). Everything else is spread
30+
// straight through, so new AuthenticateRequestOptions/VerifyTokenOptions
31+
// fields flow to the backend without another code change here.
32+
const {
33+
clerkClient: _clerkClient,
34+
debug: _debug,
35+
frontendApiProxy: _frontendApiProxy,
36+
isSatellite: isSatelliteInput,
37+
domain: domainInput,
38+
signInUrl: signInUrlInput,
39+
proxyUrl: proxyUrlInput,
40+
secretKey: secretKeyInput,
41+
machineSecretKey: machineSecretKeyInput,
42+
publishableKey: publishableKeyInput,
43+
...restOptions
44+
} = options || {};
2845

2946
const clerkRequest = createClerkRequest(incomingMessageToRequest(request));
3047
const env = { ...loadApiEnv(), ...loadClientEnv() };
3148

32-
const secretKey = options?.secretKey || env.secretKey;
33-
const machineSecretKey = options?.machineSecretKey || env.machineSecretKey;
34-
const publishableKey = options?.publishableKey || env.publishableKey;
49+
const secretKey = secretKeyInput || env.secretKey;
50+
const machineSecretKey = machineSecretKeyInput || env.machineSecretKey;
51+
const publishableKey = publishableKeyInput || env.publishableKey;
3552

36-
const isSatellite = handleValueOrFn(options?.isSatellite, clerkRequest.clerkUrl, env.isSatellite);
37-
const domain = handleValueOrFn(options?.domain, clerkRequest.clerkUrl) || env.domain;
38-
const signInUrl = options?.signInUrl || env.signInUrl;
53+
const isSatellite = handleValueOrFn(isSatelliteInput, clerkRequest.clerkUrl, env.isSatellite);
54+
const domain = handleValueOrFn(domainInput, clerkRequest.clerkUrl) || env.domain;
55+
const signInUrl = signInUrlInput || env.signInUrl;
3956
const proxyUrl = absoluteProxyUrl(
40-
handleValueOrFn(options?.proxyUrl, clerkRequest.clerkUrl, env.proxyUrl),
57+
handleValueOrFn(proxyUrlInput, clerkRequest.clerkUrl, env.proxyUrl),
4158
clerkRequest.clerkUrl.toString(),
4259
);
4360

@@ -50,18 +67,14 @@ export const authenticateRequest = (opts: AuthenticateRequestParams) => {
5067
}
5168

5269
return clerkClient.authenticateRequest(clerkRequest, {
53-
audience,
70+
...restOptions,
5471
secretKey,
5572
machineSecretKey,
5673
publishableKey,
57-
jwtKey,
58-
clockSkewInMs,
59-
authorizedParties,
6074
proxyUrl,
6175
isSatellite,
6276
domain,
6377
signInUrl,
64-
acceptsToken,
6578
});
6679
};
6780

@@ -99,8 +112,27 @@ const absoluteProxyUrl = (relativeOrAbsoluteUrl: string, baseUrl: string): strin
99112
return new URL(relativeOrAbsoluteUrl, baseUrl).toString();
100113
};
101114

115+
// `apiUrl` and `apiVersion` are pinned at client construction time inside
116+
// `@clerk/backend`'s `createAuthenticateRequest` factory (build-time values
117+
// override runtime ones). The default singleton in `./clerkClient` is built
118+
// from env only, so passing these via `clerkMiddleware()` would be silently
119+
// ignored. When the caller hasn't supplied their own `clerkClient` but did
120+
// pass `apiUrl`/`apiVersion`, build a per-middleware client with those values.
121+
const resolveDefaultClerkClient = (options: ClerkMiddlewareOptions) => {
122+
if (!options.apiUrl && !options.apiVersion) {
123+
return defaultClerkClient;
124+
}
125+
const env = { ...loadApiEnv(), ...loadClientEnv() };
126+
return createClerkClient({
127+
...env,
128+
...(options.apiUrl ? { apiUrl: options.apiUrl } : {}),
129+
...(options.apiVersion ? { apiVersion: options.apiVersion } : {}),
130+
userAgent: `${PACKAGE_NAME}@${PACKAGE_VERSION}`,
131+
});
132+
};
133+
102134
export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = {}): RequestHandler => {
103-
const clerkClient = options.clerkClient || defaultClerkClient;
135+
const clerkClient = options.clerkClient || resolveDefaultClerkClient(options);
104136

105137
// Extract proxy configuration
106138
const frontendApiProxy = options.frontendApiProxy;

0 commit comments

Comments
 (0)