diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts index 10b4a02bf62..dfaa350820e 100644 --- a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -214,6 +214,46 @@ describe('AuthenticateContext', () => { }); }); + describe('auto-proxy for .vercel.app', () => { + it('auto-derives proxyUrl for .vercel.app hostnames', async () => { + const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkTest, + }); + + expect(context.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk'); + }); + + it('does NOT auto-derive proxyUrl for non-.vercel.app domains', async () => { + const clerkRequest = createClerkRequest(new Request('https://myapp.com/dashboard')); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkTest, + }); + + expect(context.proxyUrl).toBeUndefined(); + }); + + it('explicit proxyUrl takes precedence over auto-detection', async () => { + const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkTest, + proxyUrl: 'https://custom-proxy.example.com/__clerk', + }); + + expect(context.proxyUrl).toBe('https://custom-proxy.example.com/__clerk'); + }); + + it('explicit domain skips auto-detection', async () => { + const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkTest, + domain: 'clerk.myapp.com', + }); + + expect(context.proxyUrl).toBeUndefined(); + }); + }); + // Added these tests to verify that the generated sha-1 is the same as the one used in cookie assignment // Tests copied from packages/shared/src/__tests__/keys.test.ts describe('getCookieSuffix(publishableKey, subtle)', () => { diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 6d9fdcb9c5a..9b91c8e0d45 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -1,4 +1,5 @@ import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl'; +import { isVercelPreviewDeploy } from '@clerk/shared/proxy'; import type { Jwt } from '@clerk/shared/types'; import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url'; @@ -69,6 +70,14 @@ class AuthenticateContext implements AuthenticateContext { private clerkRequest: ClerkRequest, options: AuthenticateRequestOptions, ) { + // Auto-detect proxy for Vercel preview deployments + if (!options.proxyUrl && !options.domain) { + const hostname = clerkRequest.clerkUrl.hostname; + if (isVercelPreviewDeploy(hostname)) { + options = { ...options, proxyUrl: `${clerkRequest.clerkUrl.origin}/__clerk` }; + } + } + if (options.acceptsToken === TokenType.M2MToken || options.acceptsToken === TokenType.ApiKey) { // For non-session tokens, we only want to set the header values. this.initHeaderValues(); diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 4d9fb15bc5b..41f7b6224d2 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2491,6 +2491,71 @@ describe('Clerk singleton', () => { expect(sut.getFapiClient().buildUrl({ path: '/me' }).href).toContain('https://proxy.com/api/__clerk/v1/me'); }); }); + + describe('auto-detection for .vercel.app', () => { + const originalLocation = window.location; + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + }); + }); + + test('auto-derives proxyUrl when hostname is .vercel.app', () => { + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + hostname: 'myapp-abc123.vercel.app', + origin: 'https://myapp-abc123.vercel.app', + href: 'https://myapp-abc123.vercel.app/dashboard', + }, + writable: true, + }); + + const sut = new Clerk(developmentPublishableKey); + expect(sut.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk'); + }); + + test('does NOT auto-derive proxyUrl for non-.vercel.app domains', () => { + const sut = new Clerk(developmentPublishableKey); + expect(sut.proxyUrl).toBe(''); + }); + + test('explicit proxyUrl takes precedence over auto-detection', () => { + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + hostname: 'myapp-abc123.vercel.app', + origin: 'https://myapp-abc123.vercel.app', + href: 'https://myapp-abc123.vercel.app/dashboard', + }, + writable: true, + }); + + const sut = new Clerk(developmentPublishableKey, { + proxyUrl: 'https://custom-proxy.example.com/__clerk', + }); + expect(sut.proxyUrl).toBe('https://custom-proxy.example.com/__clerk'); + }); + + test('explicit domain skips auto-detection', () => { + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + hostname: 'myapp-abc123.vercel.app', + origin: 'https://myapp-abc123.vercel.app', + href: 'https://myapp-abc123.vercel.app/dashboard', + }, + writable: true, + }); + + const sut = new Clerk(developmentPublishableKey, { + domain: 'clerk.myapp.com', + }); + expect(sut.proxyUrl).toBe(''); + }); + }); }); describe('buildUrlWithAuth', () => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b6ac5321a41..8a8f21c4b25 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -37,7 +37,7 @@ import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { parsePublishableKey } from '@clerk/shared/keys'; import { logger } from '@clerk/shared/logger'; import { CLERK_NETLIFY_CACHE_BUST_PARAM } from '@clerk/shared/netlifyCacheHandler'; -import { isHttpOrHttps, isValidProxyUrl, proxyUrlToAbsoluteURL } from '@clerk/shared/proxy'; +import { isHttpOrHttps, isValidProxyUrl, isVercelPreviewDeploy, proxyUrlToAbsoluteURL } from '@clerk/shared/proxy'; import { eventPrebuiltComponentMounted, eventPrebuiltComponentOpened, @@ -350,7 +350,14 @@ export class Clerk implements ClerkInterface { if (!isValidProxyUrl(_unfilteredProxy)) { errorThrower.throwInvalidProxyUrl({ url: _unfilteredProxy }); } - return proxyUrlToAbsoluteURL(_unfilteredProxy); + const resolved = proxyUrlToAbsoluteURL(_unfilteredProxy); + if (resolved) { + return resolved; + } + // Auto-detect for Vercel preview deployments when no explicit proxy or domain is configured + if (!this.#domain && isVercelPreviewDeploy(window.location.hostname)) { + return `${window.location.origin}/__clerk`; + } } return ''; } diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 101589ae596..ba4ce124360 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1191,6 +1191,21 @@ describe('frontendApiProxy multi-domain support', () => { }); }); +describe('auto-proxy for .vercel.app', () => { + it('auto-intercepts /__clerk/* requests on .vercel.app hostnames', async () => { + const req = new NextRequest(new URL('/__clerk/v1/client', 'https://myapp-abc123.vercel.app').toString(), { + method: 'GET', + headers: new Headers(), + }); + + const resp = await clerkMiddleware()(req, {} as NextFetchEvent); + + // Proxy should intercept the request — authenticateRequest should NOT be called + expect((await clerkClient()).authenticateRequest).not.toBeCalled(); + expect(resp?.status).toBeDefined(); + }); +}); + describe('contentSecurityPolicy option', () => { it('forwards CSP headers as request headers when strict mode is enabled', async () => { const resp = await clerkMiddleware({ diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index c50949ef99f..bdb1e592b72 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -23,6 +23,7 @@ import { import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath } from '@clerk/backend/proxy'; import { parsePublishableKey } from '@clerk/shared/keys'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; +import { isVercelPreviewDeploy } from '@clerk/shared/proxy'; import { notFound as nextjsNotFound } from 'next/navigation'; import type { NextMiddleware, NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; @@ -33,7 +34,7 @@ import { isRedirect, serverRedirectWithAuth, setHeader } from '../utils'; import { withLogger } from '../utils/debugLogger'; import { canUseKeyless } from '../utils/feature-flags'; import { clerkClient } from './clerkClient'; -import { PUBLISHABLE_KEY, SECRET_KEY, SIGN_IN_URL, SIGN_UP_URL } from './constants'; +import { DOMAIN, PROXY_URL, PUBLISHABLE_KEY, SECRET_KEY, SIGN_IN_URL, SIGN_UP_URL } from './constants'; import { type ContentSecurityPolicyOptions, createContentSecurityPolicyHeaders } from './content-security-policy'; import { errorThrower } from './errorThrower'; import { getHeader } from './headers-utils'; @@ -159,12 +160,16 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl ); // Handle Frontend API proxy requests early, before authentication - const frontendApiProxyConfig = resolvedParams.frontendApiProxy; + const requestUrl = new URL(request.url); + const frontendApiProxyConfig = + resolvedParams.frontendApiProxy ?? + (resolvedParams.proxyUrl || PROXY_URL || resolvedParams.domain || DOMAIN + ? undefined + : getAutoDetectedProxyConfig(requestUrl)); if (frontendApiProxyConfig) { const { enabled, path: proxyPath = DEFAULT_PROXY_PATH } = frontendApiProxyConfig; // Resolve enabled - either boolean or function - const requestUrl = new URL(request.url); const isEnabled = typeof enabled === 'function' ? enabled(requestUrl) : enabled; if (isEnabled && matchProxyPath(request, { proxyPath })) { @@ -576,3 +581,10 @@ const handleControlFlowErrors = ( throw e; }; + +function getAutoDetectedProxyConfig(requestUrl: URL): FrontendApiProxyOptions | undefined { + if (isVercelPreviewDeploy(requestUrl.hostname)) { + return { enabled: true }; + } + return undefined; +} diff --git a/packages/shared/src/__tests__/proxy.spec.ts b/packages/shared/src/__tests__/proxy.spec.ts index 4a898391ee6..4ac55fb692b 100644 --- a/packages/shared/src/__tests__/proxy.spec.ts +++ b/packages/shared/src/__tests__/proxy.spec.ts @@ -1,6 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { isHttpOrHttps, isProxyUrlRelative, isValidProxyUrl, proxyUrlToAbsoluteURL } from '../proxy'; +import { + isHttpOrHttps, + isProxyUrlRelative, + isValidProxyUrl, + isVercelPreviewDeploy, + proxyUrlToAbsoluteURL, +} from '../proxy'; describe('isValidProxyUrl(key)', () => { it('returns true if the proxyUrl is valid', () => { @@ -38,6 +44,28 @@ describe('isHttpOrHttps(key)', () => { }); }); +describe('isVercelPreviewDeploy(hostname)', () => { + it('returns true for a .vercel.app subdomain', () => { + expect(isVercelPreviewDeploy('myapp.vercel.app')).toBe(true); + }); + + it('returns true for a git branch preview subdomain', () => { + expect(isVercelPreviewDeploy('myapp-git-branch.vercel.app')).toBe(true); + }); + + it('returns false for the bare vercel.app domain', () => { + expect(isVercelPreviewDeploy('vercel.app')).toBe(false); + }); + + it('returns false for a custom domain', () => { + expect(isVercelPreviewDeploy('myapp.com')).toBe(false); + }); + + it('returns false for a domain that contains vercel.app but is not a subdomain', () => { + expect(isVercelPreviewDeploy('vercel.app.evil.com')).toBe(false); + }); +}); + describe('proxyUrlToAbsoluteURL(url)', () => { const currentLocation = global.window.location; diff --git a/packages/shared/src/proxy.ts b/packages/shared/src/proxy.ts index f7633ed1773..b4765a76ff8 100644 --- a/packages/shared/src/proxy.ts +++ b/packages/shared/src/proxy.ts @@ -33,6 +33,10 @@ export function proxyUrlToAbsoluteURL(url: string | undefined): string { return isProxyUrlRelative(url) ? new URL(url, window.location.origin).toString() : url; } +export function isVercelPreviewDeploy(hostname: string): boolean { + return hostname.endsWith('.vercel.app'); +} + /** * Function that determines whether proxy should be used for a given URL. */