diff --git a/.changeset/common-beers-read.md b/.changeset/common-beers-read.md new file mode 100644 index 00000000000..7f5faaf5def --- /dev/null +++ b/.changeset/common-beers-read.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Add logic to ensure that we consider the proxy_url when creating the frontendApi url. diff --git a/packages/backend/src/tokens/__tests__/handshake.test.ts b/packages/backend/src/tokens/__tests__/handshake.test.ts index f174b7f3228..036ae40d4e6 100644 --- a/packages/backend/src/tokens/__tests__/handshake.test.ts +++ b/packages/backend/src/tokens/__tests__/handshake.test.ts @@ -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) { @@ -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) { @@ -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', () => { @@ -320,4 +544,59 @@ 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 => { + const isolatedContext = { + ...mockAuthenticateContext, + proxyUrl: proxyUrl, + frontendApi: proxyUrl, + } as AuthenticateContext; + + const isolatedHandshakeService = new HandshakeService(isolatedContext, mockOptions, mockOrganizationMatcher); + + const headers = isolatedHandshakeService.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(); + }); + }); + }); }); diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 7b3c1d6aa82..cea50da15b0 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -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; } /** @@ -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. * @@ -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, @@ -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; } private sessionExpired(jwt: Jwt | undefined): boolean { diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index 3120f2aacc8..f0ef6a0f71b 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -134,13 +134,14 @@ export class HandshakeService { } const redirectUrl = this.removeDevBrowserFromURL(this.authenticateContext.clerkUrl); - const frontendApiNoProtocol = this.authenticateContext.frontendApi.replace(/http(s)?:\/\//, ''); - const baseUrl = this.authenticateContext.proxyUrl - ? this.authenticateContext.proxyUrl.replace(/\/$/, '') - : `https://${frontendApiNoProtocol}`; + let baseUrl = this.authenticateContext.frontendApi.startsWith('http') + ? this.authenticateContext.frontendApi + : `https://${this.authenticateContext.frontendApi}`; - const url = new URL(`${baseUrl}/v1/client/handshake`); + baseUrl = baseUrl.replace(/\/+$/, '') + '/'; + + const url = new URL('v1/client/handshake', baseUrl); url.searchParams.append('redirect_url', redirectUrl?.href || ''); url.searchParams.append('__clerk_api_version', SUPPORTED_BAPI_VERSION); url.searchParams.append(