diff --git a/.changeset/tiny-badgers-smile.md b/.changeset/tiny-badgers-smile.md new file mode 100644 index 00000000000..eb1c0bc9fa2 --- /dev/null +++ b/.changeset/tiny-badgers-smile.md @@ -0,0 +1,8 @@ +--- +'@clerk/backend': patch +'@clerk/clerk-js': patch +'@clerk/nextjs': patch +'@clerk/shared': patch +--- + +Add auto-proxy detection for eligible hosts, including Vercel production static-generation builds that can infer a relative proxy URL from platform env vars. diff --git a/packages/backend/src/__tests__/proxy.test.ts b/packages/backend/src/__tests__/proxy.test.ts index fdc54b47f51..672aaaf32b9 100644 --- a/packages/backend/src/__tests__/proxy.test.ts +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -481,6 +481,50 @@ describe('proxy', () => { expect(response.headers.get('Location')).toBe('https://accounts.google.com/oauth/authorize'); }); + it('sets Accept-Encoding to identity to avoid double compression', async () => { + const mockResponse = new Response(JSON.stringify({ client: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + mockFetch.mockResolvedValue(mockResponse); + + const request = new Request('https://example.com/__clerk/v1/client', { + headers: { 'Accept-Encoding': 'gzip, deflate, br' }, + }); + + await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.headers.get('Accept-Encoding')).toBe('identity'); + }); + + it('strips Content-Encoding and Content-Length from response even if upstream ignores identity', async () => { + // Upstream may ignore Accept-Encoding: identity and compress anyway + const mockResponse = new Response('decoded body', { + status: 200, + headers: { + 'Content-Type': 'application/javascript', + 'Content-Encoding': 'gzip', + 'Content-Length': '500', + }, + }); + mockFetch.mockResolvedValue(mockResponse); + + const request = new Request('https://example.com/__clerk/v1/client'); + + const response = await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + expect(response.headers.has('Content-Encoding')).toBe(false); + expect(response.headers.has('Content-Length')).toBe(false); + expect(response.headers.get('Content-Type')).toBe('application/javascript'); + }); + it('preserves relative Location headers', async () => { const mockResponse = new Response(null, { status: 302, diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts index 96b6bad11a3..4dc52aa75fd 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -54,6 +54,13 @@ const HOP_BY_HOP_HEADERS = [ 'upgrade', ]; +// Headers to strip from proxied responses. fetch() auto-decompresses +// response bodies, so Content-Encoding no longer describes the body +// and Content-Length reflects the compressed size. We request identity +// encoding upstream to avoid the double compression pass, but strip +// these defensively since servers may ignore Accept-Encoding: identity. +const RESPONSE_HEADERS_TO_STRIP = ['content-encoding', 'content-length']; + /** * Derives the Frontend API URL from a publishable key. * @param publishableKey - The Clerk publishable key @@ -235,6 +242,12 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend const fapiHost = new URL(fapiBaseUrl).host; headers.set('Host', fapiHost); + // Request uncompressed responses to avoid a double compression pass. + // fetch() auto-decompresses, so without this FAPI compresses → fetch + // decompresses → the serving layer re-compresses for the browser. + // With identity the only compression happens at the edge, closer to the client. + headers.set('Accept-Encoding', 'identity'); + // Set X-Forwarded-* headers for proxy awareness // Only set these if not already present (preserve values from upstream proxies) if (!headers.has('X-Forwarded-Host')) { @@ -271,10 +284,11 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend const response = await fetch(targetUrl.toString(), fetchOptions); - // Build response headers, excluding hop-by-hop headers + // Build response headers, excluding hop-by-hop and encoding headers const responseHeaders = new Headers(); response.headers.forEach((value, key) => { - if (!HOP_BY_HOP_HEADERS.includes(key.toLowerCase())) { + const lower = key.toLowerCase(); + if (!HOP_BY_HOP_HEADERS.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.includes(lower)) { responseHeaders.set(key, value); } }); @@ -295,11 +309,20 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend } } - return new Response(response.body, { + const proxyResponse = new Response(response.body, { status: response.status, statusText: response.statusText, headers: responseHeaders, }); + + // Some runtimes (e.g. Node 20) may re-add Content-Length when constructing + // the Response. Delete it explicitly since the body has been decompressed + // by fetch() and the original Content-Length is no longer accurate. + for (const header of RESPONSE_HEADERS_TO_STRIP) { + proxyResponse.headers.delete(header); + } + + return proxyResponse; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; return createErrorResponse('proxy_request_failed', `Failed to proxy request to Clerk FAPI: ${message}`, 502); diff --git a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts index b640a07ea79..643724cddbd 100644 --- a/packages/backend/src/tokens/__tests__/authenticateContext.test.ts +++ b/packages/backend/src/tokens/__tests__/authenticateContext.test.ts @@ -258,6 +258,46 @@ describe('AuthenticateContext', () => { }); }); + describe('auto-proxy for eligible hosts', () => { + it('auto-derives proxyUrl for eligible 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 ineligible 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 55c0ed6ad21..9b8477ef213 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 { shouldAutoProxy } 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 supported platform deployments + if (!options.proxyUrl && !options.domain) { + const hostname = clerkRequest.clerkUrl.hostname; + if (shouldAutoProxy(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..41176ab79ef 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 eligible hosts', () => { + const originalLocation = window.location; + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + }); + }); + + test('auto-derives proxyUrl when hostname is eligible', () => { + 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 ineligible 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 1a362b2edac..40babf405f3 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -38,7 +38,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, proxyUrlToAbsoluteURL, shouldAutoProxy } from '@clerk/shared/proxy'; import { eventPrebuiltComponentMounted, eventPrebuiltComponentOpened, @@ -351,7 +351,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 + if (!this.#domain && shouldAutoProxy(window.location.hostname)) { + return `${window.location.origin}/__clerk`; + } } return ''; } 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 101589ae596..b698188b92f 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1191,6 +1191,38 @@ describe('frontendApiProxy multi-domain support', () => { }); }); +describe('auto-proxy for eligible hosts', () => { + 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()(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()(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 c50949ef99f..43fd794131a 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 { shouldAutoProxy } 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.nextUrl.href); + 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 (shouldAutoProxy(requestUrl.hostname)) { + return { enabled: true }; + } + return undefined; +} 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..992f848460b 100644 --- a/packages/shared/src/proxy.ts +++ b/packages/shared/src/proxy.ts @@ -1,3 +1,5 @@ +import { isProductionFromPublishableKey } from './keys'; + /** * */ @@ -30,7 +32,61 @@ 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']; +const AUTO_PROXY_PATH = '/__clerk'; + +export function shouldAutoProxy(hostname: string): boolean { + return AUTO_PROXY_HOST_SUFFIXES.some(hostSuffix => hostname.endsWith(hostSuffix)); +} + +function normalizeHostname(hostnameOrUrl: string): string { + if (hostnameOrUrl.startsWith('http://') || hostnameOrUrl.startsWith('https://')) { + return new URL(hostnameOrUrl).hostname; + } + + return hostnameOrUrl.split('/')[0] || ''; +} + +type GetAutoProxyUrlFromEnvironmentOptions = { + publishableKey: string; + hasDomain?: boolean; + hasProxyUrl?: boolean; + environment?: NodeJS.ProcessEnv; +}; + +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 3059180e78f..c6b9f1ffb69 100644 --- a/turbo.json +++ b/turbo.json @@ -27,6 +27,8 @@ "RSDOCTOR", "TZ", "VERCEL", + "VERCEL_PROJECT_PRODUCTION_URL", + "VERCEL_TARGET_ENV", "VITE_CLERK_*" ], "globalPassThroughEnv": [