Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/tiny-badgers-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/backend': patch
'@clerk/clerk-js': patch
'@clerk/nextjs': patch
'@clerk/shared': patch
---
Comment on lines +1 to +6
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if shouldAutoProxy is a public export

# Search for shouldAutoProxy exports in the shared package
rg -n "export.*shouldAutoProxy" packages/shared/

Repository: clerk/javascript

Length of output: 152


Update version bumps from patch to minor to reflect the new public API export.

The changeset marks all packages for patch bumps, but the PR introduces a new public API function shouldAutoProxy exported from @clerk/shared. Per semantic versioning standards, new public APIs require minor version bumps, not patch (which is reserved for bug fixes). Update the changeset accordingly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.changeset/tiny-badgers-smile.md around lines 1 - 6, The changeset
incorrectly marks package bumps as 'patch' but a new public API
(shouldAutoProxy) was added; update the entries so any package that exports the
new function (at least '@clerk/shared', and any packages that re-export it like
'@clerk/backend', '@clerk/clerk-js', '@clerk/nextjs' if applicable) use 'minor'
instead of 'patch' in the .changeset/tiny-badgers-smile.md file; ensure the
header lines for those package entries read 'minor' to reflect the new public
API bump while keeping other metadata unchanged.


Add auto-proxy detection for eligible hosts, including Vercel production static-generation builds that can infer a relative proxy URL from platform env vars.
44 changes: 44 additions & 0 deletions packages/backend/src/__tests__/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 16 additions & 2 deletions packages/backend/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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);
}
});
Expand Down
40 changes: 40 additions & 0 deletions packages/backend/src/tokens/__tests__/authenticateContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();
Expand Down
65 changes: 65 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
11 changes: 9 additions & 2 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 '';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"');
});
});
32 changes: 32 additions & 0 deletions packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
18 changes: 15 additions & 3 deletions packages/nextjs/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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 })) {
Expand Down Expand Up @@ -576,3 +581,10 @@ const handleControlFlowErrors = (

throw e;
};

function getAutoDetectedProxyConfig(requestUrl: URL): FrontendApiProxyOptions | undefined {
if (shouldAutoProxy(requestUrl.hostname)) {
return { enabled: true };
}
return undefined;
}
Loading
Loading