Skip to content

Commit ce620bb

Browse files
committed
fix(express): build per-middleware ClerkClient when apiUrl/apiVersion are set
The backend's createAuthenticateRequest factory pins apiUrl/apiVersion at client construction time and overrides any runtime values. The Express default ClerkClient singleton is built from env only, so passing apiUrl or apiVersion to clerkMiddleware() was silently ignored on the default path even after option-forwarding was generalized. When the caller hasn't supplied a custom clerkClient but did pass apiUrl or apiVersion, build a per-middleware ClerkClient with those values instead of using the singleton.
1 parent 6744a83 commit ce620bb

3 files changed

Lines changed: 82 additions & 2 deletions

File tree

.changeset/express-forward-auth-options.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@
33
---
44

55
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: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,19 @@ vi.mock('@clerk/backend/proxy', async () => {
1212
};
1313
});
1414

15-
import { authenticateRequest } from '../authenticateRequest';
15+
const { mockCreateClerkClient } = vi.hoisted(() => ({
16+
mockCreateClerkClient: vi.fn(),
17+
}));
18+
vi.mock('@clerk/backend', async () => {
19+
const actual = (await vi.importActual('@clerk/backend')) as typeof import('@clerk/backend');
20+
mockCreateClerkClient.mockImplementation(actual.createClerkClient);
21+
return {
22+
...actual,
23+
createClerkClient: mockCreateClerkClient,
24+
};
25+
});
26+
27+
import { authenticateAndDecorateRequest, authenticateRequest } from '../authenticateRequest';
1628
import { clerkMiddleware } from '../clerkMiddleware';
1729
import { getAuth } from '../getAuth';
1830
import { assertNoDebugHeaders, assertSignedOutDebugHeaders, runMiddleware, runMiddlewareOnPath } from './helpers';
@@ -203,6 +215,52 @@ describe('clerkMiddleware', () => {
203215
expect(forwarded).not.toHaveProperty('frontendApiProxy');
204216
});
205217

218+
describe('apiUrl/apiVersion default-client construction', () => {
219+
beforeEach(() => {
220+
mockCreateClerkClient.mockClear();
221+
});
222+
223+
it('builds a per-middleware ClerkClient with apiUrl when no custom clerkClient is supplied', () => {
224+
authenticateAndDecorateRequest({
225+
apiUrl: 'https://api.example.test',
226+
secretKey: 'sk_test_....',
227+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
228+
});
229+
230+
expect(mockCreateClerkClient).toHaveBeenCalledWith(
231+
expect.objectContaining({ apiUrl: 'https://api.example.test' }),
232+
);
233+
});
234+
235+
it('builds a per-middleware ClerkClient with apiVersion when no custom clerkClient is supplied', () => {
236+
authenticateAndDecorateRequest({
237+
apiVersion: 'v2',
238+
secretKey: 'sk_test_....',
239+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
240+
});
241+
242+
expect(mockCreateClerkClient).toHaveBeenCalledWith(expect.objectContaining({ apiVersion: 'v2' }));
243+
});
244+
245+
it('does not call createClerkClient at construction when apiUrl/apiVersion are not set', () => {
246+
authenticateAndDecorateRequest({ secretKey: 'sk_test_....' });
247+
248+
expect(mockCreateClerkClient).not.toHaveBeenCalled();
249+
});
250+
251+
it('does not build a per-middleware client when the caller supplies their own clerkClient', () => {
252+
const customClient = { authenticateRequest: vi.fn() } as any;
253+
254+
authenticateAndDecorateRequest({
255+
apiUrl: 'https://api.example.test',
256+
apiVersion: 'v2',
257+
clerkClient: customClient,
258+
});
259+
260+
expect(mockCreateClerkClient).not.toHaveBeenCalled();
261+
});
262+
});
263+
206264
it('throws error if clerkMiddleware is not executed before getAuth', async () => {
207265
const customMiddleware: RequestHandler = (request, response, next) => {
208266
const auth = getAuth(request);

packages/express/src/authenticateRequest.ts

Lines changed: 21 additions & 1 deletion
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';
@@ -111,8 +112,27 @@ const absoluteProxyUrl = (relativeOrAbsoluteUrl: string, baseUrl: string): strin
111112
return new URL(relativeOrAbsoluteUrl, baseUrl).toString();
112113
};
113114

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+
114134
export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = {}): RequestHandler => {
115-
const clerkClient = options.clerkClient || defaultClerkClient;
135+
const clerkClient = options.clerkClient || resolveDefaultClerkClient(options);
116136

117137
// Extract proxy configuration
118138
const frontendApiProxy = options.frontendApiProxy;

0 commit comments

Comments
 (0)