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..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( { @@ -1605,5 +1630,227 @@ 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', + }); + }); + + 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 8097306851c..44b05ac6b14 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'; @@ -186,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 { @@ -198,6 +196,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 isKnownClerkReferrer(): 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..c99600c58c4 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.isKnownClerkReferrer(); // Not from Clerk accounts portal or FAPI if (shouldForceHandshakeForCrossDomain) { return handleMaybeHandshakeStatus(