Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions .changeset/common-beers-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Add logic to ensure that we consider the proxy_url when creating the frontendApi url.
274 changes: 274 additions & 0 deletions packages/backend/src/tokens/__tests__/handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ describe('HandshakeService', () => {

it('should use proxy URL when available', () => {
mockAuthenticateContext.proxyUrl = 'https://my-proxy.example.com';
// Simulate what parsePublishableKey does when proxy URL is provided
mockAuthenticateContext.frontendApi = 'https://my-proxy.example.com';
const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
Expand All @@ -195,6 +197,7 @@ describe('HandshakeService', () => {

it('should handle proxy URL with trailing slash', () => {
mockAuthenticateContext.proxyUrl = 'https://my-proxy.example.com/';
mockAuthenticateContext.frontendApi = 'https://my-proxy.example.com/';
const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
Expand All @@ -205,6 +208,227 @@ describe('HandshakeService', () => {
expect(url.hostname).toBe('my-proxy.example.com');
expect(url.pathname).toBe('/v1/client/handshake');
});

it('should handle proxy URL with multiple trailing slashes', () => {
mockAuthenticateContext.proxyUrl = 'https://my-proxy.example.com//';
mockAuthenticateContext.frontendApi = 'https://my-proxy.example.com//';
const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

expect(url.hostname).toBe('my-proxy.example.com');
expect(url.pathname).toBe('/v1/client/handshake');
expect(location).not.toContain('//v1/client/handshake');
});

it('should handle proxy URL with many trailing slashes', () => {
mockAuthenticateContext.proxyUrl = 'https://my-proxy.example.com///';
mockAuthenticateContext.frontendApi = 'https://my-proxy.example.com///';
const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

expect(url.hostname).toBe('my-proxy.example.com');
expect(url.pathname).toBe('/v1/client/handshake');
expect(location).not.toContain('//v1/client/handshake');
});

it('should handle proxy URL without trailing slash', () => {
mockAuthenticateContext.proxyUrl = 'https://my-proxy.example.com';
mockAuthenticateContext.frontendApi = 'https://my-proxy.example.com';
const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

expect(url.hostname).toBe('my-proxy.example.com');
expect(url.pathname).toBe('/v1/client/handshake');
});

it('should handle proxy URL with path and trailing slashes', () => {
mockAuthenticateContext.proxyUrl = 'https://my-proxy.example.com/clerk-proxy//';
mockAuthenticateContext.frontendApi = 'https://my-proxy.example.com/clerk-proxy//';
const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

expect(url.hostname).toBe('my-proxy.example.com');
expect(url.pathname).toBe('/clerk-proxy/v1/client/handshake');
expect(location).not.toContain('clerk-proxy//v1/client/handshake');
});

it('should handle non-HTTP frontendApi (domain only)', () => {
mockAuthenticateContext.frontendApi = 'api.clerk.com';
const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

expect(url.protocol).toBe('https:');
expect(url.hostname).toBe('api.clerk.com');
expect(url.pathname).toBe('/v1/client/handshake');
});

it('should not include dev browser token in production mode', () => {
mockAuthenticateContext.instanceType = 'production';
mockAuthenticateContext.devBrowserToken = 'dev-token';
const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

expect(url.searchParams.get(constants.QueryParameters.DevBrowser)).toBeNull();
});

it('should not include dev browser token when not available in development', () => {
mockAuthenticateContext.instanceType = 'development';
mockAuthenticateContext.devBrowserToken = undefined;
const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

expect(url.searchParams.get(constants.QueryParameters.DevBrowser)).toBeNull();
});

it('should handle usesSuffixedCookies returning false', () => {
mockAuthenticateContext.usesSuffixedCookies = vi.fn().mockReturnValue(false);
const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

expect(url.searchParams.get(constants.QueryParameters.SuffixedCookies)).toBe('false');
});

it('should include organization sync parameters when organization target is found', () => {
// Mock the organization sync methods
const mockTarget = { type: 'organization', id: 'org_123' };
const mockParams = new Map([
['org_id', 'org_123'],
['org_slug', 'test-org'],
]);

vi.spyOn(handshakeService as any, 'getOrganizationSyncTarget').mockReturnValue(mockTarget);
vi.spyOn(handshakeService as any, 'getOrganizationSyncQueryParams').mockReturnValue(mockParams);

const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

expect(url.searchParams.get('org_id')).toBe('org_123');
expect(url.searchParams.get('org_slug')).toBe('test-org');
});

it('should not include organization sync parameters when no target is found', () => {
vi.spyOn(handshakeService as any, 'getOrganizationSyncTarget').mockReturnValue(null);

const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

expect(url.searchParams.get('org_id')).toBeNull();
expect(url.searchParams.get('org_slug')).toBeNull();
});

it('should handle different handshake reasons', () => {
const reasons = ['session-token-expired', 'dev-browser-sync', 'satellite-cookie-needs-syncing'];

reasons.forEach(reason => {
const headers = handshakeService.buildRedirectToHandshake(reason);
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

expect(url.searchParams.get(constants.QueryParameters.HandshakeReason)).toBe(reason);
});
});

it('should handle complex clerkUrl with query parameters and fragments', () => {
mockAuthenticateContext.clerkUrl = new URL('https://example.com/path?existing=param#fragment');

const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

const redirectUrl = url.searchParams.get('redirect_url');
expect(redirectUrl).toBe('https://example.com/path?existing=param#fragment');
});

it('should create valid URLs with different frontend API formats', () => {
const frontendApiFormats = [
'api.clerk.com',
'https://api.clerk.com',
'https://api.clerk.com/',
'foo-bar-13.clerk.accounts.dev',
'https://foo-bar-13.clerk.accounts.dev',
'clerk.example.com',
'https://clerk.example.com/proxy-path',
];

frontendApiFormats.forEach(frontendApi => {
mockAuthenticateContext.frontendApi = frontendApi;

const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);

expect(location).toBeDefined();
if (!location) {
throw new Error('Location header should be defined');
}
expect(() => new URL(location)).not.toThrow();

const url = new URL(location);
// Path should end with '/v1/client/handshake' (may have proxy path prefix)
expect(url.pathname).toMatch(/\/v1\/client\/handshake$/);
expect(url.searchParams.get(constants.QueryParameters.HandshakeReason)).toBe('test-reason');
});
});

it('should always include required query parameters', () => {
const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);
if (!location) {
throw new Error('Location header is missing');
}
const url = new URL(location);

// Verify all required parameters are present
expect(url.searchParams.get('redirect_url')).toBeDefined();
expect(url.searchParams.get('__clerk_api_version')).toBe('2025-04-10');
expect(url.searchParams.get(constants.QueryParameters.SuffixedCookies)).toMatch(/^(true|false)$/);
expect(url.searchParams.get(constants.QueryParameters.HandshakeReason)).toBe('test-reason');
});
});

describe('handleTokenVerificationErrorInDevelopment', () => {
Expand Down Expand Up @@ -320,4 +544,54 @@ describe('HandshakeService', () => {
spy.mockRestore();
});
});

describe('URL construction edge cases', () => {
const trailingSlashTestCases = [
{ input: 'https://example.com', expected: 'https://example.com' },
{ input: 'https://example.com/', expected: 'https://example.com' },
{ input: 'https://example.com//', expected: 'https://example.com' },
{ input: 'https://example.com///', expected: 'https://example.com' },
{ input: 'https://example.com/path', expected: 'https://example.com/path' },
{ input: 'https://example.com/path/', expected: 'https://example.com/path' },
{ input: 'https://example.com/path//', expected: 'https://example.com/path' },
{ input: 'https://example.com/proxy-path///', expected: 'https://example.com/proxy-path' },
];

trailingSlashTestCases.forEach(({ input, expected }) => {
it(`should correctly handle trailing slashes: "${input}" -> "${expected}"`, () => {
const result = input.replace(/\/+$/, '');
expect(result).toBe(expected);
});
});

it('should construct valid handshake URLs with various proxy configurations', () => {
const proxyConfigs = [
'https://proxy.example.com',
'https://proxy.example.com/',
'https://proxy.example.com//',
'https://proxy.example.com/clerk',
'https://proxy.example.com/clerk/',
'https://proxy.example.com/clerk//',
'https://api.example.com/v1/clerk///',
];

proxyConfigs.forEach(proxyUrl => {
mockAuthenticateContext.proxyUrl = proxyUrl;
mockAuthenticateContext.frontendApi = proxyUrl; // Simulate parsePublishableKey behavior

const headers = handshakeService.buildRedirectToHandshake('test-reason');
const location = headers.get(constants.Headers.Location);

expect(location).toBeDefined();
if (!location) {
throw new Error('Location header should be defined');
}
expect(location).toContain('/v1/client/handshake');
expect(location).not.toContain('//v1/client/handshake'); // No double slashes

// Ensure URL is valid
expect(() => new URL(location)).not.toThrow();
});
});
});
});
38 changes: 27 additions & 11 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,33 @@ import type { AuthenticateRequestOptions } from './types';

interface AuthenticateContext extends AuthenticateRequestOptions {
// header-based values
tokenInHeader: string | undefined;
origin: string | undefined;
host: string | undefined;
accept: string | undefined;
forwardedHost: string | undefined;
forwardedProto: string | undefined;
host: string | undefined;
origin: string | undefined;
referrer: string | undefined;
userAgent: string | undefined;
secFetchDest: string | undefined;
accept: string | undefined;
tokenInHeader: string | undefined;
userAgent: string | undefined;

// cookie-based values
sessionTokenInCookie: string | undefined;
refreshTokenInCookie: string | undefined;
clientUat: number;
refreshTokenInCookie: string | undefined;
sessionTokenInCookie: string | undefined;

// handshake-related values
devBrowserToken: string | undefined;
handshakeNonce: string | undefined;
handshakeToken: string | undefined;
handshakeRedirectLoopCounter: number;
handshakeToken: string | undefined;

// url derived from headers
clerkUrl: URL;
// enforce existence of the following props
publishableKey: string;
instanceType: string;
frontendApi: string;
instanceType: string;
publishableKey: string;
}

/**
Expand All @@ -44,6 +46,12 @@ interface AuthenticateContext extends AuthenticateRequestOptions {
* to perform a handshake.
*/
class AuthenticateContext implements AuthenticateContext {
/**
* The original Clerk frontend API URL, extracted from publishable key before proxy URL override.
* Used for backend operations like token validation and issuer checking.
*/
private originalFrontendApi: string = '';

/**
* Retrieves the session token from either the cookie or the header.
*
Expand Down Expand Up @@ -163,6 +171,13 @@ class AuthenticateContext implements AuthenticateContext {
assertValidPublishableKey(options.publishableKey);
this.publishableKey = options.publishableKey;

const originalPk = parsePublishableKey(this.publishableKey, {
fatal: true,
domain: options.domain,
isSatellite: options.isSatellite,
});
this.originalFrontendApi = originalPk.frontendApi;

const pk = parsePublishableKey(this.publishableKey, {
fatal: true,
proxyUrl: options.proxyUrl,
Expand Down Expand Up @@ -266,7 +281,8 @@ class AuthenticateContext implements AuthenticateContext {
return false;
}
const tokenIssuer = data.payload.iss.replace(/https?:\/\//gi, '');
return this.frontendApi === tokenIssuer;
// Use original frontend API for token validation since tokens are issued by the actual Clerk API, not proxy
return this.originalFrontendApi === tokenIssuer;
}
Comment on lines 283 to 286
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Issuer check ignores trailing slashes / “/v1” – could produce false negatives

iss values coming from Clerk tokens usually end with /v1/ (or at least /v1).
Comparing the raw host against originalFrontendApi therefore fails for perfectly valid tokens behind a proxy.

- const tokenIssuer = data.payload.iss.replace(/https?:\/\//gi, '');
- return this.originalFrontendApi === tokenIssuer;
+ const tokenIssuer = data.payload.iss
+   .replace(/https?:\/\//gi, '')
+   .replace(/\/+v1\/?$/, '')   // strip “/v1” if present
+   .replace(/\/+$/, '');       // strip trailing slash(es)
+
+ return this.originalFrontendApi.replace(/\/+$/, '') === tokenIssuer;

This keeps proxy handling intact while tolerating the canonical Clerk issuer suffix.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const tokenIssuer = data.payload.iss.replace(/https?:\/\//gi, '');
return this.frontendApi === tokenIssuer;
// Use original frontend API for token validation since tokens are issued by the actual Clerk API, not proxy
return this.originalFrontendApi === tokenIssuer;
}
const tokenIssuer = data.payload.iss
.replace(/https?:\/\//gi, '')
.replace(/\/+v1\/?$/, '') // strip “/v1” if present
.replace(/\/+$/, ''); // strip trailing slash(es)
// Use original frontend API for token validation since tokens are issued by the actual Clerk API, not proxy
return this.originalFrontendApi
.replace(/\/+$/, '') === tokenIssuer;
}
🤖 Prompt for AI Agents
In packages/backend/src/tokens/authenticateContext.ts around lines 283 to 286,
the issuer check removes the protocol but does not account for trailing slashes
or path segments like "/v1", causing valid tokens to fail validation. Modify the
code to normalize the issuer by removing the protocol and any trailing slashes
or path suffixes such as "/v1" before comparing it to originalFrontendApi,
ensuring the comparison tolerates the canonical Clerk issuer suffix while
preserving proxy handling.


private sessionExpired(jwt: Jwt | undefined): boolean {
Expand Down
Loading
Loading