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 and generalize the internal helper naming for future providers.
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider making the description more specific for end users.

The description mentions "eligible hosts" without specifying what makes a host eligible. Since changeset descriptions appear in changelogs, consider being more explicit to help users understand when auto-proxy detection activates.

For example: "Add auto-proxy detection for Vercel preview deployments (.vercel.app subdomains) and generalize the internal helper for future hosting providers."

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 8-8: First line in a file should be a top-level heading

(MD041, first-line-heading, first-line-h1)

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

In @.changeset/tiny-badgers-smile.md at line 8, Update the changeset description
sentence that currently reads "Add auto-proxy detection for eligible hosts and
generalize the internal helper naming for future providers." to be specific
about which hosts are considered eligible (e.g., Vercel preview deployments) and
to clarify the helper change; for example, change the description to: "Add
auto-proxy detection for Vercel preview deployments (.vercel.app subdomains) and
generalize the internal helper for future hosting providers." Locate and edit
the line containing that description in the changeset (the sentence beginning
"Add auto-proxy detection for eligible hosts...") and replace it with the
clearer, user-facing wording.

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
15 changes: 15 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,21 @@ 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();
});
});

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.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 })) {
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;
}
24 changes: 23 additions & 1 deletion packages/shared/src/__tests__/proxy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { isHttpOrHttps, isProxyUrlRelative, isValidProxyUrl, proxyUrlToAbsoluteURL } from '../proxy';
import { isHttpOrHttps, isProxyUrlRelative, isValidProxyUrl, proxyUrlToAbsoluteURL, shouldAutoProxy } from '../proxy';

describe('isValidProxyUrl(key)', () => {
it('returns true if the proxyUrl is valid', () => {
Expand Down Expand Up @@ -38,6 +38,28 @@ 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('proxyUrlToAbsoluteURL(url)', () => {
const currentLocation = global.window.location;

Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export function proxyUrlToAbsoluteURL(url: string | undefined): string {
return isProxyUrlRelative(url) ? new URL(url, window.location.origin).toString() : url;
}

const AUTO_PROXY_HOST_SUFFIXES = ['.vercel.app'];

export function shouldAutoProxy(hostname: string): boolean {
return AUTO_PROXY_HOST_SUFFIXES.some(hostSuffix => hostname.endsWith(hostSuffix));
}

/**
* Function that determines whether proxy should be used for a given URL.
*/
Expand Down
Loading