From c1afe34f3fa390f0ea8c953a153802afd3f98f69 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Tue, 1 Jul 2025 16:22:14 -0500 Subject: [PATCH 1/5] Handles cross origin syncing on primary domains --- .../src/tokens/__tests__/request.test.ts | 157 +++++++++++++++++- packages/backend/src/tokens/authStatus.ts | 1 + .../backend/src/tokens/authenticateContext.ts | 20 +++ packages/backend/src/tokens/request.ts | 14 ++ 4 files changed, 191 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 8cb17ed8beb..0634ee544a3 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -193,7 +193,8 @@ expect.extend({ }; } else { return { - message: () => `expected to be signed in, but got ${received.status}`, + message: () => + `expected to be signed in, but got ${received.status} (${JSON.stringify(received)}, ${JSON.stringify(expected)})`, pass: false, }; } @@ -1396,4 +1397,158 @@ describe('tokens.authenticateRequest(options)', () => { }); }); }); + + describe('Cross-origin sync', () => { + beforeEach(() => { + server.use( + http.get('https://api.clerk.test/v1/jwks', () => { + return HttpResponse.json(mockJwks); + }), + ); + }); + + test('triggers handshake for cross-origin document request on primary domain', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://satellite.com/signin', + 'sec-fetch-dest': 'document', + origin: 'https://primary.com', + }, + { + __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', + }); + }); + + test('does not trigger handshake when referer is same origin', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://primary.com/signin', + 'sec-fetch-dest': 'document', + origin: 'https://primary.com', + }, + { + __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 no referer header', async () => { + const request = mockRequestWithCookies( + { + 'sec-fetch-dest': 'document', + origin: 'https://primary.com', + }, + { + __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 for non-document requests', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://satellite.com/signin', + 'sec-fetch-dest': 'empty', + origin: 'https://primary.com', + }, + { + __session: mockJwt, + __client_uat: '12345', + }, + 'https://primary.com/api/data', + ); + + 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 header contains invalid URL format', async () => { + const request = mockRequestWithCookies( + { + referer: 'invalid-url-format', + 'sec-fetch-dest': 'document', + origin: 'https://primary.com', + }, + { + __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', + }); + }); + }); }); diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index d0c60eaecbd..881e5f40ca6 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -107,6 +107,7 @@ export const AuthErrorReason = { DevBrowserMissing: 'dev-browser-missing', DevBrowserSync: 'dev-browser-sync', PrimaryRespondsToSyncing: 'primary-responds-to-syncing', + PrimaryDomainCrossOriginSync: 'primary-domain-cross-origin-sync', SatelliteCookieNeedsSyncing: 'satellite-needs-syncing', SessionTokenAndUATMissing: 'session-token-and-uat-missing', SessionTokenMissing: 'session-token-missing', diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index cea50da15b0..2ddc59218fe 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -167,6 +167,26 @@ class AuthenticateContext implements AuthenticateContext { return true; } + /** + * Determines if the request came from a different origin based on the referrer header. + * Used for cross-origin detection in multi-domain authentication flows. + * + * @returns {boolean} True if referrer exists and is from a different origin, false otherwise. + */ + public isCrossOriginReferrer(): boolean { + if (!this.referrer || !this.origin) { + return false; + } + + try { + const referrerOrigin = new URL(this.referrer).origin; + return referrerOrigin !== this.origin; + } catch { + // Invalid referrer 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 d212f568432..a8064934a8e 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -553,6 +553,20 @@ export const authenticateRequest: AuthenticateRequest = (async ( token: authenticateContext.sessionTokenInCookie!, }); + // Check for cross-origin requests from satellite domains to primary domain + const shouldForceHandshakeForCrossDomain = + !authenticateContext.isSatellite && // We're on primary + authenticateContext.secFetchDest === 'document' && // Document navigation + authenticateContext.isCrossOriginReferrer(); // Came from different domain + + if (shouldForceHandshakeForCrossDomain) { + return handleMaybeHandshakeStatus( + authenticateContext, + AuthErrorReason.PrimaryDomainCrossOriginSync, + 'Cross-origin request from satellite domain requires handshake', + ); + } + const authObject = signedInRequestState.toAuth(); // Org sync if necessary if (authObject.userId) { From 34cc6c9c7e3ca1917fa5d52477cfb2e49e634198 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Tue, 1 Jul 2025 16:28:00 -0500 Subject: [PATCH 2/5] adds changeset --- .changeset/orange-bikes-prove.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/orange-bikes-prove.md diff --git a/.changeset/orange-bikes-prove.md b/.changeset/orange-bikes-prove.md new file mode 100644 index 00000000000..0802a6e90e2 --- /dev/null +++ b/.changeset/orange-bikes-prove.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Trigger a handshake on a signed in, cross origin request to sync session state from a satellite domain. From 25bd7599da18f27e5d26d27d5e30fc708193a074 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Tue, 1 Jul 2025 16:29:09 -0500 Subject: [PATCH 3/5] Update packages/backend/src/tokens/__tests__/request.test.ts --- packages/backend/src/tokens/__tests__/request.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 0634ee544a3..f1e877fe2b2 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -194,7 +194,7 @@ expect.extend({ } else { return { message: () => - `expected to be signed in, but got ${received.status} (${JSON.stringify(received)}, ${JSON.stringify(expected)})`, + `expected to be signed in, but got ${received.status}`, pass: false, }; } From 6a6e29b801ed3ab62995ecce2f7b54a3db613891 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 1 Jul 2025 19:56:06 -0500 Subject: [PATCH 4/5] format --- packages/backend/src/tokens/__tests__/request.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index f1e877fe2b2..e548a31ebc2 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -193,8 +193,7 @@ expect.extend({ }; } else { return { - message: () => - `expected to be signed in, but got ${received.status}`, + message: () => `expected to be signed in, but got ${received.status}`, pass: false, }; } From 87a854fcf533bb43d0d62a10eac1683a817d4807 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Wed, 2 Jul 2025 15:04:28 -0500 Subject: [PATCH 5/5] check sec-fetch-site header --- packages/backend/src/constants.ts | 1 + .../src/tokens/__tests__/request.test.ts | 30 +++++++++++++++++++ .../backend/src/tokens/authenticateContext.ts | 4 +++ 3 files changed, 35 insertions(+) diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index 08e90a3164f..2a04b3e2bac 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -63,6 +63,7 @@ const Headers = { Origin: 'origin', Referrer: 'referer', SecFetchDest: 'sec-fetch-dest', + SecFetchSite: 'sec-fetch-site', UserAgent: 'user-agent', ReportingEndpoints: 'reporting-endpoints', } as const; diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index e548a31ebc2..9419688ca90 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1435,6 +1435,36 @@ describe('tokens.authenticateRequest(options)', () => { }); }); + test('triggers handshake for cross-site document request on primary domain', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://satellite.com/signin', + 'sec-fetch-dest': 'document', + 'sec-fetch-site': 'cross-site', + origin: 'https://primary.com', + }, + { + __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', + }); + }); + test('does not trigger handshake when referer is same origin', async () => { const request = mockRequestWithCookies( { diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 2ddc59218fe..9bd4fedd335 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -179,6 +179,10 @@ 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.origin; } catch {