diff --git a/.changeset/auto-proxy-vercel-subdomains.md b/.changeset/auto-proxy-vercel-subdomains.md new file mode 100644 index 00000000000..54671b8867d --- /dev/null +++ b/.changeset/auto-proxy-vercel-subdomains.md @@ -0,0 +1,8 @@ +--- +'@clerk/shared': patch +'@clerk/backend': patch +'@clerk/clerk-js': patch +'@clerk/nextjs': patch +--- + +Auto-proxy FAPI requests for `.vercel.app` subdomains. When deployed to a `.vercel.app` domain without explicit proxy or domain configuration, the SDK automatically routes Frontend API requests through `/__clerk` on the app's own origin. This enables Clerk production mode on Vercel deployments without manual proxy setup. diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts index b640a07ea79..064d5e960c7 100644 --- a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -258,6 +258,71 @@ describe('AuthenticateContext', () => { }); }); + describe('auto-proxy for eligible hosts', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + VERCEL_TARGET_ENV: 'production', + VERCEL_PROJECT_PRODUCTION_URL: 'myapp-abc123.vercel.app', + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('auto-derives proxyUrl when Vercel env vars indicate production vercel.app', async () => { + const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkLive, + }); + + expect(context.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk'); + }); + + it('does NOT auto-derive proxyUrl for development keys', async () => { + const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkTest, + }); + + expect(context.proxyUrl).toBeUndefined(); + }); + + it('does NOT auto-derive proxyUrl when Vercel env vars are absent', async () => { + delete process.env.VERCEL_TARGET_ENV; + delete process.env.VERCEL_PROJECT_PRODUCTION_URL; + const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard')); + const context = await createAuthenticateContext(clerkRequest, { + publishableKey: pkLive, + }); + + 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: pkLive, + 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: pkLive, + 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 19fb89001c0..794c9268874 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 { getAutoProxyUrlFromEnvironment } from '@clerk/shared/proxy'; import type { Jwt } from '@clerk/shared/types'; import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url'; @@ -70,6 +71,18 @@ class AuthenticateContext implements AuthenticateContext { private clerkRequest: ClerkRequest, options: AuthenticateRequestOptions, ) { + // Auto-detect proxy for supported platform deployments using environment + // variables (e.g. VERCEL_TARGET_ENV, VERCEL_PROJECT_PRODUCTION_URL) instead + // of request headers, which avoids X-Forwarded-Host spoofing concerns. + const autoProxyPath = getAutoProxyUrlFromEnvironment({ + publishableKey: options.publishableKey ?? '', + hasProxyUrl: !!options.proxyUrl, + hasDomain: !!options.domain, + }); + if (autoProxyPath) { + options = { ...options, proxyUrl: `${clerkRequest.clerkUrl.origin}${autoProxyPath}` }; + } + 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 4a539c55147..979cf6e24fa 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2516,6 +2516,86 @@ describe('Clerk singleton', () => { }); }); }); + + describe('auto-detection for eligible hosts', () => { + const originalLocation = window.location; + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + }); + }); + + test('auto-derives proxyUrl for production instances on eligible hosts', () => { + 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(productionPublishableKey); + expect(sut.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk'); + }); + + test('does NOT auto-derive proxyUrl for development instances on eligible hosts', () => { + 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(''); + }); + + test('does NOT auto-derive proxyUrl for ineligible domains', () => { + const sut = new Clerk(productionPublishableKey); + 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(productionPublishableKey, { + 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(productionPublishableKey, { + 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 fe4da6a8e91..47cbfb2515f 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -38,7 +38,13 @@ 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 { + AUTO_PROXY_PATH, + isHttpOrHttps, + isValidProxyUrl, + proxyUrlToAbsoluteURL, + shouldAutoProxy, +} from '@clerk/shared/proxy'; import { eventPrebuiltComponentMounted, eventPrebuiltComponentOpened, @@ -361,7 +367,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 when no explicit proxy or domain is configured (production only) + if (!this.#domain && this.#instanceType === 'production' && shouldAutoProxy(window.location.hostname)) { + return `${window.location.origin}${AUTO_PROXY_PATH}`; + } } if (typeof this.#proxyUrl === 'function') { diff --git a/packages/nextjs/src/app-router/server/__tests__/DynamicClerkScripts.test.tsx b/packages/nextjs/src/app-router/server/__tests__/DynamicClerkScripts.test.tsx index e9c787bc1f8..2e4a4111e40 100644 --- a/packages/nextjs/src/app-router/server/__tests__/DynamicClerkScripts.test.tsx +++ b/packages/nextjs/src/app-router/server/__tests__/DynamicClerkScripts.test.tsx @@ -86,4 +86,24 @@ describe('DynamicClerkScripts', () => { expect(html).not.toContain('nonce="test'); expect(html).not.toContain('nonce="csp'); }); + + it('renders initial script tags with relative proxied asset URLs', async () => { + mockHeaders.mockResolvedValue( + new Map([ + ['X-Nonce', null], + ['Content-Security-Policy', ''], + ]), + ); + + const html = await render( + DynamicClerkScripts({ + ...defaultProps, + proxyUrl: '/__clerk', + }), + ); + + expect(html).toContain('src="/__clerk/npm/@clerk/clerk-js@'); + expect(html).toContain('href="/__clerk/npm/@clerk/ui@'); + expect(html).toContain('data-clerk-proxy-url="/__clerk"'); + }); }); diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 683eca9b11f..31757419d0f 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1326,6 +1326,40 @@ describe('frontendApiProxy multi-domain support', () => { }); }); +describe('auto-proxy for eligible hosts', () => { + const productionPublishableKey = 'pk_live_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA'; + + it('auto-intercepts /__clerk/* requests on eligible 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({ publishableKey: productionPublishableKey })(req, {} as NextFetchEvent); + + // Proxy should intercept the request — authenticateRequest should NOT be called + expect((await clerkClient()).authenticateRequest).not.toBeCalled(); + expect(resp?.status).toBeDefined(); + }); + + it('uses request.nextUrl for auto-detection', async () => { + const req = new NextRequest('http://127.0.0.1:3000/__clerk/v1/client', { + method: 'GET', + headers: new Headers(), + }); + + Object.defineProperty(req, 'nextUrl', { + value: new URL('https://myapp-abc123.vercel.app/__clerk/v1/client'), + configurable: true, + }); + + const resp = await clerkMiddleware({ publishableKey: productionPublishableKey })(req, {} as NextFetchEvent); + + 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 20a69a8c24e..f8c3cd32da9 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -21,9 +21,10 @@ import { TokenType, } from '@clerk/backend/internal'; import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath } from '@clerk/backend/proxy'; -import { parsePublishableKey } from '@clerk/shared/keys'; +import { isProductionFromPublishableKey, parsePublishableKey } from '@clerk/shared/keys'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; import { isMalformedURLError } from '@clerk/shared/pathMatcher'; +import { shouldAutoProxy } from '@clerk/shared/proxy'; import { notFound as nextjsNotFound } from 'next/navigation'; import type { NextMiddleware, NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; @@ -35,7 +36,7 @@ import type { Logger, LoggerNoCommit } from '../utils/debugLogger'; 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'; @@ -161,12 +162,20 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl ); // Handle Frontend API proxy requests early, before authentication - const frontendApiProxyConfig = resolvedParams.frontendApiProxy; + const requestUrl = new URL(request.nextUrl.href); + let frontendApiProxyConfig = resolvedParams.frontendApiProxy; + + // Auto-detect when no explicit proxy or domain is configured + const hasExplicitProxyOrDomain = resolvedParams.proxyUrl || PROXY_URL || resolvedParams.domain || DOMAIN; + if (!frontendApiProxyConfig && !hasExplicitProxyOrDomain && isProductionFromPublishableKey(publishableKey)) { + if (shouldAutoProxy(requestUrl.hostname)) { + frontendApiProxyConfig = { enabled: true }; + } + } 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 })) { diff --git a/packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts b/packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts new file mode 100644 index 00000000000..c83bdb54d0a --- /dev/null +++ b/packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { mergeNextClerkPropsWithEnv } from '../mergeNextClerkPropsWithEnv'; + +const ORIGINAL_ENV = { ...process.env }; + +describe('mergeNextClerkPropsWithEnv', () => { + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('auto-derives a relative proxyUrl for Vercel production static generation', () => { + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_live_Zm9vLmNsZXJrLmNvbSQ='; + process.env.VERCEL_TARGET_ENV = 'production'; + process.env.VERCEL_PROJECT_PRODUCTION_URL = 'myapp.vercel.app'; + + const result = mergeNextClerkPropsWithEnv({}); + + expect(result.proxyUrl).toBe('/__clerk'); + }); + + it('does not auto-derive proxyUrl for non-production Clerk keys', () => { + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_test_Zm9vLmNsZXJrLmFjY291bnRzLmRldiQ='; + process.env.VERCEL_TARGET_ENV = 'production'; + process.env.VERCEL_PROJECT_PRODUCTION_URL = 'myapp.vercel.app'; + + const result = mergeNextClerkPropsWithEnv({}); + + expect(result.proxyUrl).toBe(''); + }); + + it('does not auto-derive proxyUrl outside Vercel production deployments', () => { + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_live_Zm9vLmNsZXJrLmNvbSQ='; + process.env.VERCEL_TARGET_ENV = 'preview'; + process.env.VERCEL_PROJECT_PRODUCTION_URL = 'myapp.vercel.app'; + + const result = mergeNextClerkPropsWithEnv({}); + + expect(result.proxyUrl).toBe(''); + }); + + it('does not auto-derive proxyUrl when the Vercel production hostname is not eligible', () => { + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_live_Zm9vLmNsZXJrLmNvbSQ='; + process.env.VERCEL_TARGET_ENV = 'production'; + process.env.VERCEL_PROJECT_PRODUCTION_URL = 'myapp.com'; + + const result = mergeNextClerkPropsWithEnv({}); + + expect(result.proxyUrl).toBe(''); + }); + + it('does not override an explicit proxyUrl', () => { + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_live_Zm9vLmNsZXJrLmNvbSQ='; + process.env.VERCEL_TARGET_ENV = 'production'; + process.env.VERCEL_PROJECT_PRODUCTION_URL = 'myapp.vercel.app'; + + const result = mergeNextClerkPropsWithEnv({ + proxyUrl: 'https://custom-proxy.example.com/__clerk', + }); + + expect(result.proxyUrl).toBe('https://custom-proxy.example.com/__clerk'); + }); + + it('does not derive proxyUrl when an explicit domain is configured', () => { + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_live_Zm9vLmNsZXJrLmNvbSQ='; + process.env.VERCEL_TARGET_ENV = 'production'; + process.env.VERCEL_PROJECT_PRODUCTION_URL = 'myapp.vercel.app'; + + const result = mergeNextClerkPropsWithEnv({ + domain: 'clerk.myapp.com', + }); + + expect(result.proxyUrl).toBe(''); + }); +}); diff --git a/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts b/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts index afb09022061..491e6cf810d 100644 --- a/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts +++ b/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts @@ -1,4 +1,5 @@ import type { InternalClerkScriptProps } from '@clerk/react/internal'; +import { getAutoProxyUrlFromEnvironment } from '@clerk/shared/proxy'; import { isTruthy } from '@clerk/shared/underscore'; import { SDK_METADATA } from '../server/constants'; @@ -22,16 +23,26 @@ function getPrefetchUIFromEnvAndProps(propsPrefetchUI: NextClerkProviderProps['p export const mergeNextClerkPropsWithEnv = ( props: Omit & InternalClerkScriptProps, ): any => { + const publishableKey = props.publishableKey || process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY || ''; + const proxyUrl = props.proxyUrl || process.env.NEXT_PUBLIC_CLERK_PROXY_URL || ''; + const domain = props.domain || process.env.NEXT_PUBLIC_CLERK_DOMAIN || ''; + return { ...props, - publishableKey: props.publishableKey || process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY || '', + publishableKey, __internal_clerkJSUrl: props.__internal_clerkJSUrl || process.env.NEXT_PUBLIC_CLERK_JS_URL, __internal_clerkJSVersion: props.__internal_clerkJSVersion || process.env.NEXT_PUBLIC_CLERK_JS_VERSION, __internal_clerkUIUrl: props.__internal_clerkUIUrl || process.env.NEXT_PUBLIC_CLERK_UI_URL, __internal_clerkUIVersion: props.__internal_clerkUIVersion || process.env.NEXT_PUBLIC_CLERK_UI_VERSION, prefetchUI: getPrefetchUIFromEnvAndProps(props.prefetchUI), - proxyUrl: props.proxyUrl || process.env.NEXT_PUBLIC_CLERK_PROXY_URL || '', - domain: props.domain || process.env.NEXT_PUBLIC_CLERK_DOMAIN || '', + proxyUrl: + proxyUrl || + getAutoProxyUrlFromEnvironment({ + hasDomain: !!domain, + hasProxyUrl: !!proxyUrl, + publishableKey, + }), + domain, isSatellite: props.isSatellite || isTruthy(process.env.NEXT_PUBLIC_CLERK_IS_SATELLITE), signInUrl: props.signInUrl || process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL || '', signUpUrl: props.signUpUrl || process.env.NEXT_PUBLIC_CLERK_SIGN_UP_URL || '', diff --git a/packages/shared/src/__tests__/loadClerkJsScript.spec.ts b/packages/shared/src/__tests__/loadClerkJsScript.spec.ts index 69eeedf576e..81191d47072 100644 --- a/packages/shared/src/__tests__/loadClerkJsScript.spec.ts +++ b/packages/shared/src/__tests__/loadClerkJsScript.spec.ts @@ -169,6 +169,11 @@ describe('clerkJsScriptUrl()', () => { const result = clerkJsScriptUrl({ publishableKey: mockDevPublishableKey, __internal_clerkJSVersion: '6' }); expect(result).toContain('/npm/@clerk/clerk-js@6/'); }); + + test('constructs a relative proxied URL when proxyUrl is relative', () => { + const result = clerkJsScriptUrl({ publishableKey: mockDevPublishableKey, proxyUrl: '/__clerk' }); + expect(result).toBe(`/__clerk/npm/@clerk/clerk-js@${jsPackageMajorVersion}/dist/clerk.browser.js`); + }); }); describe('buildScriptHost()', () => { @@ -228,6 +233,26 @@ describe('buildScriptHost()', () => { writable: true, }); }); + + test('falls back to frontendApi for relative proxyUrl when window is unavailable', () => { + const currentWindow = global.window; + + try { + Object.defineProperty(global, 'window', { + value: undefined, + configurable: true, + }); + + const result = buildScriptHost({ publishableKey: mockDevPublishableKey, proxyUrl: '/__clerk' }); + expect(result).toBe('foo-bar-13.clerk.accounts.dev'); + } finally { + Object.defineProperty(global, 'window', { + value: currentWindow, + writable: true, + configurable: true, + }); + } + }); }); describe('buildClerkJsScriptAttributes()', () => { @@ -425,6 +450,11 @@ describe('clerkUIScriptUrl()', () => { expect(uiResult).not.toContain('@clerk/clerk-js'); expect(jsResult).not.toContain('@clerk/ui'); }); + + test('constructs a relative proxied URL when proxyUrl is relative', () => { + const result = clerkUIScriptUrl({ publishableKey: mockDevPublishableKey, proxyUrl: '/__clerk' }); + expect(result).toBe(`/__clerk/npm/@clerk/ui@${uiPackageMajorVersion}/dist/ui.browser.js`); + }); }); describe('buildClerkUIScriptAttributes()', () => { diff --git a/packages/shared/src/__tests__/proxy.spec.ts b/packages/shared/src/__tests__/proxy.spec.ts index 4a898391ee6..b09143e2f4a 100644 --- a/packages/shared/src/__tests__/proxy.spec.ts +++ b/packages/shared/src/__tests__/proxy.spec.ts @@ -1,6 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { isHttpOrHttps, isProxyUrlRelative, isValidProxyUrl, proxyUrlToAbsoluteURL } from '../proxy'; +import { + getAutoProxyUrlFromEnvironment, + isHttpOrHttps, + isProxyUrlRelative, + isValidProxyUrl, + proxyUrlToAbsoluteURL, + shouldAutoProxy, +} from '../proxy'; describe('isValidProxyUrl(key)', () => { it('returns true if the proxyUrl is valid', () => { @@ -38,6 +45,100 @@ describe('isHttpOrHttps(key)', () => { }); }); +describe('shouldAutoProxy(hostname)', () => { + it('returns true for a .vercel.app subdomain', () => { + expect(shouldAutoProxy('myapp.vercel.app')).toBe(true); + }); + + it('returns true for a git branch preview subdomain', () => { + expect(shouldAutoProxy('myapp-git-branch.vercel.app')).toBe(true); + }); + + it('returns false for the bare vercel.app domain', () => { + expect(shouldAutoProxy('vercel.app')).toBe(false); + }); + + it('returns false for a custom domain', () => { + expect(shouldAutoProxy('myapp.com')).toBe(false); + }); + + it('returns false for a domain that contains vercel.app but is not a subdomain', () => { + expect(shouldAutoProxy('vercel.app.evil.com')).toBe(false); + }); +}); + +describe('getAutoProxyUrlFromEnvironment(options)', () => { + it('returns a relative proxy path for Vercel production deployments with production keys', () => { + expect( + getAutoProxyUrlFromEnvironment({ + publishableKey: 'pk_live_Zm9vLmNsZXJrLmNvbSQ=', + environment: { + VERCEL_PROJECT_PRODUCTION_URL: 'myapp.vercel.app', + VERCEL_TARGET_ENV: 'production', + }, + }), + ).toBe('/__clerk'); + }); + + it('returns empty string for non-production Clerk keys', () => { + expect( + getAutoProxyUrlFromEnvironment({ + publishableKey: 'pk_test_Zm9vLmNsZXJrLmFjY291bnRzLmRldiQ=', + environment: { + VERCEL_PROJECT_PRODUCTION_URL: 'myapp.vercel.app', + VERCEL_TARGET_ENV: 'production', + }, + }), + ).toBe(''); + }); + + it('returns empty string when an explicit domain or proxyUrl is configured', () => { + expect( + getAutoProxyUrlFromEnvironment({ + hasDomain: true, + publishableKey: 'pk_live_Zm9vLmNsZXJrLmNvbSQ=', + environment: { + VERCEL_PROJECT_PRODUCTION_URL: 'myapp.vercel.app', + VERCEL_TARGET_ENV: 'production', + }, + }), + ).toBe(''); + + expect( + getAutoProxyUrlFromEnvironment({ + hasProxyUrl: true, + publishableKey: 'pk_live_Zm9vLmNsZXJrLmNvbSQ=', + environment: { + VERCEL_PROJECT_PRODUCTION_URL: 'myapp.vercel.app', + VERCEL_TARGET_ENV: 'production', + }, + }), + ).toBe(''); + }); + + it('returns empty string for ineligible or non-production Vercel environments', () => { + expect( + getAutoProxyUrlFromEnvironment({ + publishableKey: 'pk_live_Zm9vLmNsZXJrLmNvbSQ=', + environment: { + VERCEL_PROJECT_PRODUCTION_URL: 'myapp.com', + VERCEL_TARGET_ENV: 'production', + }, + }), + ).toBe(''); + + expect( + getAutoProxyUrlFromEnvironment({ + publishableKey: 'pk_live_Zm9vLmNsZXJrLmNvbSQ=', + environment: { + VERCEL_PROJECT_PRODUCTION_URL: 'myapp.vercel.app', + VERCEL_TARGET_ENV: 'preview', + }, + }), + ).toBe(''); + }); +}); + describe('proxyUrlToAbsoluteURL(url)', () => { const currentLocation = global.window.location; @@ -66,6 +167,24 @@ describe('proxyUrlToAbsoluteURL(url)', () => { it('returns the same value as the parameter given as it already an absolute URL', () => { expect(proxyUrlToAbsoluteURL('https://clerk.com/api/__clerk')).toBe('https://clerk.com/api/__clerk'); }); + + it('returns the relative URL unchanged when window is unavailable', () => { + const currentWindow = global.window; + + Object.defineProperty(global, 'window', { + value: undefined, + configurable: true, + }); + + expect(proxyUrlToAbsoluteURL('/api/__clerk')).toBe('/api/__clerk'); + + Object.defineProperty(global, 'window', { + value: currentWindow, + writable: true, + configurable: true, + }); + }); + it('returns empty string if parameter is undefined', () => { expect(proxyUrlToAbsoluteURL(undefined)).toBe(''); }); diff --git a/packages/shared/src/loadClerkJsScript.ts b/packages/shared/src/loadClerkJsScript.ts index 96171f5c648..917c34268e1 100644 --- a/packages/shared/src/loadClerkJsScript.ts +++ b/packages/shared/src/loadClerkJsScript.ts @@ -1,7 +1,7 @@ import { buildErrorThrower, ClerkRuntimeError } from './error'; import { createDevOrStagingUrlCache, parsePublishableKey } from './keys'; import { loadScript } from './loadScript'; -import { isValidProxyUrl, proxyUrlToAbsoluteURL } from './proxy'; +import { isProxyUrlRelative, isValidProxyUrl, proxyUrlToAbsoluteURL } from './proxy'; import type { SDKMetadata } from './types'; import { addClerkPrefix } from './url'; import { versionSelector } from './versionSelector'; @@ -230,8 +230,13 @@ export const clerkJSScriptUrl = (opts: LoadClerkJSScriptOptions) => { return __internal_clerkJSUrl; } - const scriptHost = buildScriptHost({ publishableKey, proxyUrl, domain }); const version = versionSelector(__internal_clerkJSVersion); + + if (proxyUrl && isProxyUrlRelative(proxyUrl)) { + return buildRelativeProxyScriptUrl(proxyUrl, 'clerk-js', version, 'clerk.browser.js'); + } + + const scriptHost = buildScriptHost({ publishableKey, proxyUrl, domain }); return `https://${scriptHost}/npm/@clerk/clerk-js@${version}/dist/clerk.browser.js`; }; @@ -242,8 +247,13 @@ export const clerkUIScriptUrl = (opts: LoadClerkUIScriptOptions) => { return __internal_clerkUIUrl; } - const scriptHost = buildScriptHost({ publishableKey, proxyUrl, domain }); const version = versionSelector(__internal_clerkUIVersion, UI_PACKAGE_VERSION); + + if (proxyUrl && isProxyUrlRelative(proxyUrl)) { + return buildRelativeProxyScriptUrl(proxyUrl, 'ui', version, 'ui.browser.js'); + } + + const scriptHost = buildScriptHost({ publishableKey, proxyUrl, domain }); return `https://${scriptHost}/npm/@clerk/ui@${version}/dist/ui.browser.js`; }; @@ -280,11 +290,29 @@ const applyAttributesToScript = (attributes: Record) => (script: } }; +const stripTrailingSlashes = (value: string) => { + while (value.endsWith('/')) { + value = value.slice(0, -1); + } + + return value; +}; + +const buildRelativeProxyScriptUrl = (proxyUrl: string, packageName: string, version: string, fileName: string) => { + return `${stripTrailingSlashes(proxyUrl)}/npm/@clerk/${packageName}@${version}/dist/${fileName}`; +}; + export const buildScriptHost = (opts: { publishableKey: string; proxyUrl?: string; domain?: string }) => { const { proxyUrl, domain, publishableKey } = opts; if (!!proxyUrl && isValidProxyUrl(proxyUrl)) { - return proxyUrlToAbsoluteURL(proxyUrl).replace(/http(s)?:\/\//, ''); + const resolvedProxyUrl = proxyUrlToAbsoluteURL(proxyUrl); + + if (isProxyUrlRelative(resolvedProxyUrl)) { + return parsePublishableKey(publishableKey)?.frontendApi || ''; + } + + return resolvedProxyUrl.replace(/http(s)?:\/\//, ''); } else if (domain && !isDevOrStagingUrl(parsePublishableKey(publishableKey)?.frontendApi || '')) { return addClerkPrefix(domain); } else { diff --git a/packages/shared/src/proxy.ts b/packages/shared/src/proxy.ts index f7633ed1773..6413accf405 100644 --- a/packages/shared/src/proxy.ts +++ b/packages/shared/src/proxy.ts @@ -1,3 +1,5 @@ +import { isProductionFromPublishableKey } from './keys'; + /** * */ @@ -30,7 +32,71 @@ export function proxyUrlToAbsoluteURL(url: string | undefined): string { if (!url) { return ''; } - return isProxyUrlRelative(url) ? new URL(url, window.location.origin).toString() : url; + + if (!isProxyUrlRelative(url)) { + return url; + } + + if (typeof window === 'undefined' || !window.location?.origin) { + return url; + } + + return new URL(url, window.location.origin).toString(); +} + +const AUTO_PROXY_HOST_SUFFIXES = ['.vercel.app']; +export const AUTO_PROXY_PATH = '/__clerk'; + +export function shouldAutoProxy(hostname: string): boolean { + return AUTO_PROXY_HOST_SUFFIXES.some(hostSuffix => hostname?.endsWith(hostSuffix)) ?? false; +} + +function normalizeHostname(hostnameOrUrl: string): string { + if (hostnameOrUrl.startsWith('http://') || hostnameOrUrl.startsWith('https://')) { + try { + return new URL(hostnameOrUrl).hostname; + } catch { + return ''; + } + } + + return hostnameOrUrl.split('/')[0] || ''; +} + +type GetAutoProxyUrlFromEnvironmentOptions = { + publishableKey: string; + hasDomain?: boolean; + hasProxyUrl?: boolean; + environment?: NodeJS.ProcessEnv; +}; + +/** + * Determines if the current Vercel environment should use auto-proxy. + * Note: This runs both at build time (static generation) and at runtime + * (server-side rendering) via mergeNextClerkPropsWithEnv in providers. + * The return value may become the proxyUrl or the script src prefix. + */ +export function getAutoProxyUrlFromEnvironment({ + publishableKey, + hasDomain = false, + hasProxyUrl = false, + environment = process.env, +}: GetAutoProxyUrlFromEnvironmentOptions): string { + if (hasProxyUrl || hasDomain || !isProductionFromPublishableKey(publishableKey)) { + return ''; + } + + if (environment.VERCEL_TARGET_ENV !== 'production') { + return ''; + } + + const vercelProductionHostname = environment.VERCEL_PROJECT_PRODUCTION_URL; + + if (!vercelProductionHostname || !shouldAutoProxy(normalizeHostname(vercelProductionHostname))) { + return ''; + } + + return AUTO_PROXY_PATH; } /** diff --git a/turbo.json b/turbo.json index 6c977c64dc3..0c18ed94be5 100644 --- a/turbo.json +++ b/turbo.json @@ -27,6 +27,8 @@ "RSDOCTOR", "TZ", "VERCEL", + "VERCEL_PROJECT_PRODUCTION_URL", + "VERCEL_TARGET_ENV", "VITE_CLERK_*" ], "globalPassThroughEnv": [