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. 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 8cb17ed8beb..9419688ca90 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1396,4 +1396,188 @@ 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('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( + { + 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..9bd4fedd335 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -167,6 +167,30 @@ 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 { + if (this.getHeader(constants.Headers.SecFetchSite) === 'cross-site') { + return true; + } + + 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) {