diff --git a/.changeset/brave-lions-fly.md b/.changeset/brave-lions-fly.md new file mode 100644 index 00000000000..4975e66360e --- /dev/null +++ b/.changeset/brave-lions-fly.md @@ -0,0 +1,6 @@ +--- +"@clerk/shared": patch +"@clerk/react": patch +--- + +Add `publishableKeyFromHost` utility for resolving the correct publishable key per hostname in multi-domain setups. Re-exported from `@clerk/react/internal`. diff --git a/.changeset/new-kangaroos-search.md b/.changeset/new-kangaroos-search.md new file mode 100644 index 00000000000..2e38f3138cf --- /dev/null +++ b/.changeset/new-kangaroos-search.md @@ -0,0 +1,5 @@ +--- +"@clerk/express": patch +--- + +Support dynamic options callback in `clerkMiddleware` for multi-domain and multi-tenant setups. diff --git a/packages/express/src/__tests__/clerkMiddleware.test.ts b/packages/express/src/__tests__/clerkMiddleware.test.ts index f1c9bdbc9d9..4fb8688d067 100644 --- a/packages/express/src/__tests__/clerkMiddleware.test.ts +++ b/packages/express/src/__tests__/clerkMiddleware.test.ts @@ -245,6 +245,54 @@ describe('clerkMiddleware', () => { }); }); + describe('with options callback', () => { + it('accepts a callback function and resolves options per request', async () => { + const optionsCallback = vi.fn().mockResolvedValue({ + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_....', + }); + + const response = await runMiddleware(clerkMiddleware(optionsCallback), { + Cookie: '__clerk_db_jwt=deadbeef;', + }).expect(200, 'Hello world!'); + + expect(optionsCallback).toHaveBeenCalledOnce(); + assertSignedOutDebugHeaders(response); + }); + + it('calls the callback with the incoming request', async () => { + let capturedHostname: string | undefined; + + const optionsCallback = vi.fn().mockImplementation((req: Request) => { + capturedHostname = req.hostname; + return Promise.resolve({ + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_....', + }); + }); + + await runMiddleware(clerkMiddleware(optionsCallback), { + Cookie: '__clerk_db_jwt=deadbeef;', + Host: 'example.com', + }).expect(200, 'Hello world!'); + + expect(capturedHostname).toBe('example.com'); + }); + + it('accepts a synchronous callback (non-Promise return)', async () => { + const optionsCallback = vi.fn().mockReturnValue({ + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_....', + }); + + const response = await runMiddleware(clerkMiddleware(optionsCallback), { + Cookie: '__clerk_db_jwt=deadbeef;', + }).expect(200, 'Hello world!'); + + assertSignedOutDebugHeaders(response); + }); + }); + it('calls next with an error when request URL is invalid', () => { const req = { url: '//', diff --git a/packages/express/src/clerkMiddleware.ts b/packages/express/src/clerkMiddleware.ts index ac360542697..43a86437461 100644 --- a/packages/express/src/clerkMiddleware.ts +++ b/packages/express/src/clerkMiddleware.ts @@ -1,13 +1,17 @@ import type { RequestHandler } from 'express'; import { authenticateAndDecorateRequest } from './authenticateRequest'; -import type { ClerkMiddlewareOptions } from './types'; +import type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './types'; /** * Middleware that integrates Clerk authentication into your Express application. * It checks the request's cookies and headers for a session JWT and, if found, * attaches the Auth object to the request object under the `auth` key. * + * Accepts either a static options object or a callback that receives the request + * and returns options. The callback form is useful for multi-domain setups where + * the publishable key differs per domain. + * * @example * app.use(clerkMiddleware(options)); * @@ -17,14 +21,36 @@ import type { ClerkMiddlewareOptions } from './types'; * * @example * app.use(clerkMiddleware()); + * + * @example + * // Dynamic keys per domain + * app.use(clerkMiddleware((req) => ({ + * publishableKey: req.hostname === 'example.com' ? PK_A : PK_B, + * }))); */ -export const clerkMiddleware = (options: ClerkMiddlewareOptions = {}): RequestHandler => { - const authMiddleware = authenticateAndDecorateRequest({ - ...options, - acceptsToken: 'any', - }); +export const clerkMiddleware = ( + options: ClerkMiddlewareOptions | ClerkMiddlewareOptionsCallback = {}, +): RequestHandler => { + if (typeof options !== 'function') { + const authMiddleware = authenticateAndDecorateRequest({ + ...options, + acceptsToken: 'any', + }); + return (request, response, next) => { + authMiddleware(request, response, next); + }; + } - return (request, response, next) => { - authMiddleware(request, response, next); + return async (request, response, next) => { + try { + const resolvedOptions = await options(request); + const handler = authenticateAndDecorateRequest({ + ...resolvedOptions, + acceptsToken: 'any', + }); + handler(request, response, next); + } catch (err) { + next(err); + } }; }; diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index b64dc029cf4..29851e1e4d4 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -2,7 +2,7 @@ export * from '@clerk/backend'; export { clerkClient } from './clerkClient'; -export type { ExpressRequestWithAuth } from './types'; +export type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback, ExpressRequestWithAuth } from './types'; export { clerkMiddleware } from './clerkMiddleware'; export { getAuth } from './getAuth'; export { requireAuth } from './requireAuth'; diff --git a/packages/express/src/types.ts b/packages/express/src/types.ts index 9eb03b77462..4d889de3dbb 100644 --- a/packages/express/src/types.ts +++ b/packages/express/src/types.ts @@ -27,6 +27,10 @@ export interface FrontendApiProxyOptions { path?: string; } +export type ClerkMiddlewareOptionsCallback = ( + req: ExpressRequest, +) => ClerkMiddlewareOptions | Promise; + export type ClerkMiddlewareOptions = AuthenticateRequestOptions & { debug?: boolean; clerkClient?: ClerkClient; diff --git a/packages/react/src/internal.ts b/packages/react/src/internal.ts index 0161dab0279..99f90c77fbe 100644 --- a/packages/react/src/internal.ts +++ b/packages/react/src/internal.ts @@ -5,6 +5,7 @@ import type React from 'react'; import { ClerkProvider } from './contexts/ClerkProvider'; import type { ClerkProviderProps } from './types'; +export { publishableKeyFromHost } from '@clerk/shared/keys'; export { setErrorThrowerOptions } from './errors/errorThrower'; export { MultisessionAppSupport } from './components/controlComponents'; export { useOAuthConsent } from '@clerk/shared/react'; diff --git a/packages/shared/src/__tests__/keys.spec.ts b/packages/shared/src/__tests__/keys.spec.ts index a4e493171ac..4242d8111dd 100644 --- a/packages/shared/src/__tests__/keys.spec.ts +++ b/packages/shared/src/__tests__/keys.spec.ts @@ -10,6 +10,7 @@ import { isProductionFromSecretKey, isPublishableKey, parsePublishableKey, + publishableKeyFromHost, } from '../keys'; describe('buildPublishableKey(frontendApi)', () => { @@ -245,6 +246,46 @@ describe('isProductionFromSecretKey(key)', () => { }); }); +describe('publishableKeyFromHost(host, fallbackKey?)', () => { + it('derives a pk_live_ key from a production hostname', () => { + const result = publishableKeyFromHost('example.com'); + expect(result).toMatch(/^pk_live_/); + expect(result).toBe(buildPublishableKey('clerk.example.com')); + }); + + it('lowercases the host before deriving', () => { + expect(publishableKeyFromHost('Example.COM')).toBe(publishableKeyFromHost('example.com')); + }); + + it('returns the fallbackKey as-is when it is a development key', () => { + const devKey = 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk'; + expect(publishableKeyFromHost('localhost', devKey)).toBe(devKey); + }); + + it('derives from host when fallbackKey is a production key', () => { + const prodKey = 'pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ='; + const result = publishableKeyFromHost('custom-domain.com', prodKey); + expect(result).toMatch(/^pk_live_/); + expect(result).toBe(buildPublishableKey('clerk.custom-domain.com')); + }); + + it('derives from host when no fallbackKey is provided', () => { + expect(publishableKeyFromHost('custom-domain.com')).toBe(buildPublishableKey('clerk.custom-domain.com')); + }); + + it('strips the port from the host before deriving', () => { + expect(publishableKeyFromHost('example.com:3000')).toBe(publishableKeyFromHost('example.com')); + }); + + it('strips the port even when combined with case normalization', () => { + expect(publishableKeyFromHost('Example.COM:8080')).toBe(publishableKeyFromHost('example.com')); + }); + + it('throws when host is empty', () => { + expect(() => publishableKeyFromHost('')).toThrow('Host must not be empty.'); + }); +}); + describe('getCookieSuffix(publishableKey, subtle?)', () => { const cases: Array<[string, string]> = [ ['pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', 'qReyu04C'], diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 9b4ac37a02a..c9013a45c83 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -43,6 +43,41 @@ export function buildPublishableKey(frontendApi: string): string { return `${keyPrefix}${isomorphicBtoa(`${frontendApi}$`)}`; } +/** + * Derives a publishable key from the current hostname. Intended for multi-domain + * setups (e.g. custom domains on top of a default domain) where the correct key + * must be resolved per request. + * + * Pass the configured publishable key as `fallbackKey` so that development + * instances (pk_test_) are returned as-is instead of being incorrectly derived + * from the host (e.g. localhost). + * + * @example + * // React (use window.location.hostname, not window.location.host, to avoid including the port) + * + * + * @example + * // Express (inside clerkMiddleware callback) + * // Validate req.hostname against a known allowlist before passing it in. + * // When `trust proxy` is enabled, req.hostname reads from X-Forwarded-Host + * // and can be spoofed if your proxy is not properly configured. + * const ALLOWED_HOSTS = ['domain-a.com', 'domain-b.com']; + * clerkMiddleware((req) => { + * if (!ALLOWED_HOSTS.includes(req.hostname)) throw new Error('Unknown host'); + * return { publishableKey: publishableKeyFromHost(req.hostname, process.env.CLERK_PUBLISHABLE_KEY) }; + * }) + */ +export function publishableKeyFromHost(host: string, fallbackKey?: string): string { + if (fallbackKey && isDevelopmentFromPublishableKey(fallbackKey)) { + return fallbackKey; + } + const hostname = host.toLowerCase().replace(/:\d+$/, ''); + if (!hostname) { + throw new Error('Host must not be empty.'); + } + return buildPublishableKey(`clerk.${hostname}`); +} + /** * Validates that a decoded publishable key has the correct format. * The decoded value should be a frontend API followed by exactly one '$' at the end.