From ffde23d405325615e61498cbef9f0f6fb49ef633 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 21 Aug 2025 11:23:15 -0500 Subject: [PATCH 1/4] Update logic for cross origin sync handshake case to ignore known auth referers --- .changeset/yellow-vans-walk.md | 5 + .../src/tokens/__tests__/request.test.ts | 191 ++++++++++++++++++ .../backend/src/tokens/authenticateContext.ts | 52 +++++ packages/backend/src/tokens/request.ts | 3 +- 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 .changeset/yellow-vans-walk.md diff --git a/.changeset/yellow-vans-walk.md b/.changeset/yellow-vans-walk.md new file mode 100644 index 00000000000..25b12cc745c --- /dev/null +++ b/.changeset/yellow-vans-walk.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Fix logic for forcing a session sync on cross origin requests. diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 426f1e90aaf..8c4b25a2c9e 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1605,5 +1605,196 @@ describe('tokens.authenticateRequest(options)', () => { signInUrl: 'https://primary.com/sign-in', }); }); + + test('does not trigger handshake when referer is from production accounts portal', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://accounts.example.com/sign-in', + 'sec-fetch-dest': 'document', + 'sec-fetch-site': 'cross-site', + }, + { + __session: mockJwt, + __client_uat: '12345', + }, + 'https://primary.com/dashboard', + ); + + const requestState = await authenticateRequest(request, { + ...mockOptions(), + publishableKey: PK_LIVE, + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + + expect(requestState).toBeSignedIn({ + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + }); + + test('does not trigger handshake when referer is from dev accounts portal (current format)', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://foo-bar-13.accounts.dev/sign-in', + 'sec-fetch-dest': 'document', + 'sec-fetch-site': 'cross-site', + }, + { + __session: mockJwt, + __client_uat: '12345', + }, + 'https://primary.com/dashboard', + ); + + const requestState = await authenticateRequest(request, { + ...mockOptions(), + publishableKey: PK_LIVE, + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + + expect(requestState).toBeSignedIn({ + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + }); + + test('does not trigger handshake when referer is from dev accounts portal (legacy format)', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in', + 'sec-fetch-dest': 'document', + 'sec-fetch-site': 'cross-site', + }, + { + __session: mockJwt, + __client_uat: '12345', + }, + 'https://primary.com/dashboard', + ); + + const requestState = await authenticateRequest(request, { + ...mockOptions(), + publishableKey: PK_LIVE, + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + + expect(requestState).toBeSignedIn({ + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + }); + + test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in', + 'sec-fetch-dest': 'document', + 'sec-fetch-site': 'cross-site', + }, + { + __session: mockJwt, + __client_uat: '12345', + }, + 'https://primary.com/dashboard', + ); + + const requestState = await authenticateRequest(request, { + ...mockOptions(), + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + + // Should not trigger the specific cross-origin sync handshake we're trying to prevent + expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync); + }); + + test('does not trigger handshake when referer is from FAPI domain (redirect-based auth)', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://clerk.inspired.puma-74.lcl.dev/v1/client/sign_ins/12345/attempt_first_factor', + 'sec-fetch-dest': 'document', + 'sec-fetch-site': 'cross-site', + }, + { + __session: mockJwt, + __client_uat: '12345', + }, + 'https://primary.com/dashboard', + ); + + const requestState = await authenticateRequest(request, { + ...mockOptions(), + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + + // Should not trigger the specific cross-origin sync handshake we're trying to prevent + expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync); + }); + + test('does not trigger handshake when referer is from FAPI domain with https prefix', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://clerk.inspired.puma-74.lcl.dev/sign-in', + 'sec-fetch-dest': 'document', + 'sec-fetch-site': 'cross-site', + }, + { + __session: mockJwt, + __client_uat: '12345', + }, + 'https://primary.com/dashboard', + ); + + const requestState = await authenticateRequest(request, { + ...mockOptions(), + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + + // Should not trigger the specific cross-origin sync handshake we're trying to prevent + expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync); + }); + + test('still triggers handshake for legitimate cross-origin requests from non-accounts domains', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://satellite.com/sign-in', + 'sec-fetch-dest': 'document', + 'sec-fetch-site': 'cross-site', + }, + { + __session: mockJwt, + __client_uat: '12345', + }, + 'https://primary.com/dashboard', + ); + + const requestState = await authenticateRequest(request, { + ...mockOptions(), + publishableKey: PK_LIVE, + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + + expect(requestState).toMatchHandshake({ + reason: AuthErrorReason.PrimaryDomainCrossOriginSync, + domain: 'primary.com', + signInUrl: 'https://primary.com/sign-in', + }); + }); }); }); diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 8097306851c..9fa6c8e7ee9 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -1,3 +1,5 @@ +import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl'; +import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url'; import type { Jwt } from '@clerk/types'; import { constants } from '../constants'; @@ -198,6 +200,56 @@ class AuthenticateContext implements AuthenticateContext { } } + /** + * Determines if the referrer URL is from a Clerk domain (accounts portal or FAPI). + * This includes both development and production account portal domains, as well as FAPI domains + * used for redirect-based authentication flows. + * + * @returns {boolean} True if the referrer is from a Clerk accounts portal or FAPI domain, false otherwise + */ + public isClerkDomain(): boolean { + if (!this.referrer) { + return false; + } + + try { + const referrerOrigin = new URL(this.referrer); + const referrerHost = referrerOrigin.hostname; + + // Check if referrer is the FAPI domain itself (redirect-based auth flows) + if (this.frontendApi) { + const fapiHost = this.frontendApi.startsWith('http') ? new URL(this.frontendApi).hostname : this.frontendApi; + if (referrerHost === fapiHost) { + return true; + } + } + + // Check for development account portal patterns + if (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) { + return true; + } + + // Check for production account portal by comparing with expected accounts URL + const expectedAccountsUrl = buildAccountsBaseUrl(this.frontendApi); + if (expectedAccountsUrl) { + const expectedAccountsOrigin = new URL(expectedAccountsUrl).origin; + if (referrerOrigin.origin === expectedAccountsOrigin) { + return true; + } + } + + // Check for generic production accounts patterns (accounts.*) + if (referrerHost.startsWith('accounts.')) { + return true; + } + + return false; + } catch { + // Invalid URL format + return false; + } + } + private initPublishableKeyValues(options: AuthenticateRequestOptions) { assertValidPublishableKey(options.publishableKey); this.publishableKey = options.publishableKey; diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 595e6ea5742..9f7a20c5b1d 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -576,7 +576,8 @@ export const authenticateRequest: AuthenticateRequest = (async ( const shouldForceHandshakeForCrossDomain = !authenticateContext.isSatellite && // We're on primary authenticateContext.secFetchDest === 'document' && // Document navigation - authenticateContext.isCrossOriginReferrer(); // Came from different domain + authenticateContext.isCrossOriginReferrer() && // Came from different domain + !authenticateContext.isClerkDomain(); // Not from Clerk accounts portal or FAPI if (shouldForceHandshakeForCrossDomain) { return handleMaybeHandshakeStatus( From 376b4e44482833fd8972fb4f952d112c60796568 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 22 Aug 2025 15:15:25 -0500 Subject: [PATCH 2/4] remove cross-site check --- .../src/tokens/__tests__/request.test.ts | 31 +++++++++++++++++++ .../backend/src/tokens/authenticateContext.ts | 4 --- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 8c4b25a2c9e..c999da34d3f 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1796,5 +1796,36 @@ describe('tokens.authenticateRequest(options)', () => { signInUrl: 'https://primary.com/sign-in', }); }); + + test('does not trigger handshake when referrer matches current origin despite sec-fetch-site cross-site (redirect chain)', async () => { + const request = mockRequestWithCookies( + { + host: 'primary.com', + referer: 'https://primary.com/some-page', + 'sec-fetch-dest': 'document', + 'sec-fetch-site': 'cross-site', // This can happen due to redirect chains through Clerk domains + }, + { + __session: mockJwt, + __client_uat: '12345', + }, + 'https://primary.com/dashboard', + ); + + const requestState = await authenticateRequest(request, { + ...mockOptions(), + publishableKey: PK_LIVE, + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + + // Should not trigger handshake because referrer origin matches current origin + expect(requestState).toBeSignedIn({ + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + }); }); }); diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 9fa6c8e7ee9..4271f68f00d 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -188,10 +188,6 @@ class AuthenticateContext implements AuthenticateContext { } try { - if (this.getHeader(constants.Headers.SecFetchSite) === 'cross-site') { - return true; - } - const referrerOrigin = new URL(this.referrer).origin; return referrerOrigin !== this.clerkUrl.origin; } catch { From 1c7deff449e74afb22e0ffe889bc189e10ccae9e Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 22 Aug 2025 16:51:21 -0500 Subject: [PATCH 3/4] adds test case --- .../src/tokens/__tests__/request.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index c999da34d3f..29b51852f40 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1520,6 +1520,31 @@ describe('tokens.authenticateRequest(options)', () => { }); }); + test('does not trigger handshake when referer is same origin', async () => { + const request = mockRequestWithCookies( + { + host: 'localhost:3000', + referer: 'http://localhost:3000', + 'sec-fetch-dest': 'document', + }, + { + __clerk_db_jwt: mockJwt, + __session: mockJwt, + __client_uat: '12345', + }, + 'http://localhost:3000', + ); + + const requestState = await authenticateRequest(request, { + ...mockOptions(), + signInUrl: 'http://localhost:3000/sign-in', + }); + + expect(requestState).toBeSignedIn({ + signInUrl: 'http://localhost:3000/sign-in', + }); + }); + test('does not trigger handshake when no referer header', async () => { const request = mockRequestWithCookies( { From 8b14d907b62a4890f7f36eeb8bbf66cb74b1abca Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Mon, 25 Aug 2025 10:11:16 -0500 Subject: [PATCH 4/4] rename method --- packages/backend/src/tokens/authenticateContext.ts | 2 +- packages/backend/src/tokens/request.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 4271f68f00d..44b05ac6b14 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -203,7 +203,7 @@ class AuthenticateContext implements AuthenticateContext { * * @returns {boolean} True if the referrer is from a Clerk accounts portal or FAPI domain, false otherwise */ - public isClerkDomain(): boolean { + public isKnownClerkReferrer(): boolean { if (!this.referrer) { return false; } diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 9f7a20c5b1d..c99600c58c4 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -577,7 +577,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( !authenticateContext.isSatellite && // We're on primary authenticateContext.secFetchDest === 'document' && // Document navigation authenticateContext.isCrossOriginReferrer() && // Came from different domain - !authenticateContext.isClerkDomain(); // Not from Clerk accounts portal or FAPI + !authenticateContext.isKnownClerkReferrer(); // Not from Clerk accounts portal or FAPI if (shouldForceHandshakeForCrossDomain) { return handleMaybeHandshakeStatus(