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
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 @@ -214,6 +214,46 @@ describe('AuthenticateContext', () => {
});
});

describe('auto-proxy for .vercel.app', () => {
it('auto-derives proxyUrl for .vercel.app hostnames', async () => {
const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard'));
const context = await createAuthenticateContext(clerkRequest, {
publishableKey: pkTest,
});

expect(context.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk');
});

it('does NOT auto-derive proxyUrl for non-.vercel.app domains', async () => {
const clerkRequest = createClerkRequest(new Request('https://myapp.com/dashboard'));
const context = await createAuthenticateContext(clerkRequest, {
publishableKey: pkTest,
});

expect(context.proxyUrl).toBeUndefined();
});

it('explicit proxyUrl takes precedence over auto-detection', async () => {
const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard'));
const context = await createAuthenticateContext(clerkRequest, {
publishableKey: pkTest,
proxyUrl: 'https://custom-proxy.example.com/__clerk',
});

expect(context.proxyUrl).toBe('https://custom-proxy.example.com/__clerk');
});

it('explicit domain skips auto-detection', async () => {
const clerkRequest = createClerkRequest(new Request('https://myapp-abc123.vercel.app/dashboard'));
const context = await createAuthenticateContext(clerkRequest, {
publishableKey: pkTest,
domain: 'clerk.myapp.com',
});

expect(context.proxyUrl).toBeUndefined();
});
});

// Added these tests to verify that the generated sha-1 is the same as the one used in cookie assignment
// Tests copied from packages/shared/src/__tests__/keys.test.ts
describe('getCookieSuffix(publishableKey, subtle)', () => {
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 { isVercelPreviewDeploy } 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 Vercel preview deployments
if (!options.proxyUrl && !options.domain) {
const hostname = clerkRequest.clerkUrl.hostname;
if (isVercelPreviewDeploy(hostname)) {
options = { ...options, proxyUrl: `${clerkRequest.clerkUrl.origin}/__clerk` };
}
}

if (options.acceptsToken === TokenType.M2MToken || options.acceptsToken === TokenType.ApiKey) {
// For non-session tokens, we only want to set the header values.
this.initHeaderValues();
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 .vercel.app', () => {
const originalLocation = window.location;

afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
});
});

test('auto-derives proxyUrl when hostname is .vercel.app', () => {
Object.defineProperty(window, 'location', {
value: {
...originalLocation,
hostname: 'myapp-abc123.vercel.app',
origin: 'https://myapp-abc123.vercel.app',
href: 'https://myapp-abc123.vercel.app/dashboard',
},
writable: true,
});

const sut = new Clerk(developmentPublishableKey);
expect(sut.proxyUrl).toBe('https://myapp-abc123.vercel.app/__clerk');
});

test('does NOT auto-derive proxyUrl for non-.vercel.app domains', () => {
const sut = new Clerk(developmentPublishableKey);
expect(sut.proxyUrl).toBe('');
});

test('explicit proxyUrl takes precedence over auto-detection', () => {
Object.defineProperty(window, 'location', {
value: {
...originalLocation,
hostname: 'myapp-abc123.vercel.app',
origin: 'https://myapp-abc123.vercel.app',
href: 'https://myapp-abc123.vercel.app/dashboard',
},
writable: true,
});

const sut = new Clerk(developmentPublishableKey, {
proxyUrl: 'https://custom-proxy.example.com/__clerk',
});
expect(sut.proxyUrl).toBe('https://custom-proxy.example.com/__clerk');
});

test('explicit domain skips auto-detection', () => {
Object.defineProperty(window, 'location', {
value: {
...originalLocation,
hostname: 'myapp-abc123.vercel.app',
origin: 'https://myapp-abc123.vercel.app',
href: 'https://myapp-abc123.vercel.app/dashboard',
},
writable: true,
});

const sut = new Clerk(developmentPublishableKey, {
domain: 'clerk.myapp.com',
});
expect(sut.proxyUrl).toBe('');
});
});
});

describe('buildUrlWithAuth', () => {
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 @@ -37,7 +37,7 @@ import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { parsePublishableKey } from '@clerk/shared/keys';
import { logger } from '@clerk/shared/logger';
import { CLERK_NETLIFY_CACHE_BUST_PARAM } from '@clerk/shared/netlifyCacheHandler';
import { isHttpOrHttps, isValidProxyUrl, proxyUrlToAbsoluteURL } from '@clerk/shared/proxy';
import { isHttpOrHttps, isValidProxyUrl, isVercelPreviewDeploy, proxyUrlToAbsoluteURL } from '@clerk/shared/proxy';
import {
eventPrebuiltComponentMounted,
eventPrebuiltComponentOpened,
Expand Down Expand Up @@ -350,7 +350,14 @@ export class Clerk implements ClerkInterface {
if (!isValidProxyUrl(_unfilteredProxy)) {
errorThrower.throwInvalidProxyUrl({ url: _unfilteredProxy });
}
return proxyUrlToAbsoluteURL(_unfilteredProxy);
const resolved = proxyUrlToAbsoluteURL(_unfilteredProxy);
if (resolved) {
return resolved;
}
// Auto-detect for Vercel preview deployments when no explicit proxy or domain is configured
if (!this.#domain && isVercelPreviewDeploy(window.location.hostname)) {
return `${window.location.origin}/__clerk`;
}
}
return '';
}
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 .vercel.app', () => {
it('auto-intercepts /__clerk/* requests on .vercel.app hostnames', async () => {
const req = new NextRequest(new URL('/__clerk/v1/client', 'https://myapp-abc123.vercel.app').toString(), {
method: 'GET',
headers: new Headers(),
});

const resp = await clerkMiddleware()(req, {} as NextFetchEvent);

// Proxy should intercept the request — authenticateRequest should NOT be called
expect((await clerkClient()).authenticateRequest).not.toBeCalled();
expect(resp?.status).toBeDefined();
});
});

describe('contentSecurityPolicy option', () => {
it('forwards CSP headers as request headers when strict mode is enabled', async () => {
const resp = await clerkMiddleware({
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 { isVercelPreviewDeploy } 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 (isVercelPreviewDeploy(requestUrl.hostname)) {
return { enabled: true };
}
return undefined;
}
30 changes: 29 additions & 1 deletion packages/shared/src/__tests__/proxy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

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

describe('isValidProxyUrl(key)', () => {
it('returns true if the proxyUrl is valid', () => {
Expand Down Expand Up @@ -38,6 +44,28 @@ describe('isHttpOrHttps(key)', () => {
});
});

describe('isVercelPreviewDeploy(hostname)', () => {
it('returns true for a .vercel.app subdomain', () => {
expect(isVercelPreviewDeploy('myapp.vercel.app')).toBe(true);
});

it('returns true for a git branch preview subdomain', () => {
expect(isVercelPreviewDeploy('myapp-git-branch.vercel.app')).toBe(true);
});

it('returns false for the bare vercel.app domain', () => {
expect(isVercelPreviewDeploy('vercel.app')).toBe(false);
});

it('returns false for a custom domain', () => {
expect(isVercelPreviewDeploy('myapp.com')).toBe(false);
});

it('returns false for a domain that contains vercel.app but is not a subdomain', () => {
expect(isVercelPreviewDeploy('vercel.app.evil.com')).toBe(false);
});
});

describe('proxyUrlToAbsoluteURL(url)', () => {
const currentLocation = global.window.location;

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

export function isVercelPreviewDeploy(hostname: string): boolean {
return hostname.endsWith('.vercel.app');
}

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