Skip to content

Commit 453cf86

Browse files
authored
fix(backend): Update logic for cross origin sync handshake (#6600)
1 parent 5ea03d8 commit 453cf86

File tree

4 files changed

+306
-5
lines changed

4 files changed

+306
-5
lines changed

.changeset/yellow-vans-walk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': patch
3+
---
4+
5+
Fix logic for forcing a session sync on cross origin requests.

packages/backend/src/tokens/__tests__/request.test.ts

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,6 +1520,31 @@ describe('tokens.authenticateRequest(options)', () => {
15201520
});
15211521
});
15221522

1523+
test('does not trigger handshake when referer is same origin', async () => {
1524+
const request = mockRequestWithCookies(
1525+
{
1526+
host: 'localhost:3000',
1527+
referer: 'http://localhost:3000',
1528+
'sec-fetch-dest': 'document',
1529+
},
1530+
{
1531+
__clerk_db_jwt: mockJwt,
1532+
__session: mockJwt,
1533+
__client_uat: '12345',
1534+
},
1535+
'http://localhost:3000',
1536+
);
1537+
1538+
const requestState = await authenticateRequest(request, {
1539+
...mockOptions(),
1540+
signInUrl: 'http://localhost:3000/sign-in',
1541+
});
1542+
1543+
expect(requestState).toBeSignedIn({
1544+
signInUrl: 'http://localhost:3000/sign-in',
1545+
});
1546+
});
1547+
15231548
test('does not trigger handshake when no referer header', async () => {
15241549
const request = mockRequestWithCookies(
15251550
{
@@ -1605,5 +1630,227 @@ describe('tokens.authenticateRequest(options)', () => {
16051630
signInUrl: 'https://primary.com/sign-in',
16061631
});
16071632
});
1633+
1634+
test('does not trigger handshake when referer is from production accounts portal', async () => {
1635+
const request = mockRequestWithCookies(
1636+
{
1637+
referer: 'https://accounts.example.com/sign-in',
1638+
'sec-fetch-dest': 'document',
1639+
'sec-fetch-site': 'cross-site',
1640+
},
1641+
{
1642+
__session: mockJwt,
1643+
__client_uat: '12345',
1644+
},
1645+
'https://primary.com/dashboard',
1646+
);
1647+
1648+
const requestState = await authenticateRequest(request, {
1649+
...mockOptions(),
1650+
publishableKey: PK_LIVE,
1651+
domain: 'primary.com',
1652+
isSatellite: false,
1653+
signInUrl: 'https://primary.com/sign-in',
1654+
});
1655+
1656+
expect(requestState).toBeSignedIn({
1657+
domain: 'primary.com',
1658+
isSatellite: false,
1659+
signInUrl: 'https://primary.com/sign-in',
1660+
});
1661+
});
1662+
1663+
test('does not trigger handshake when referer is from dev accounts portal (current format)', async () => {
1664+
const request = mockRequestWithCookies(
1665+
{
1666+
referer: 'https://foo-bar-13.accounts.dev/sign-in',
1667+
'sec-fetch-dest': 'document',
1668+
'sec-fetch-site': 'cross-site',
1669+
},
1670+
{
1671+
__session: mockJwt,
1672+
__client_uat: '12345',
1673+
},
1674+
'https://primary.com/dashboard',
1675+
);
1676+
1677+
const requestState = await authenticateRequest(request, {
1678+
...mockOptions(),
1679+
publishableKey: PK_LIVE,
1680+
domain: 'primary.com',
1681+
isSatellite: false,
1682+
signInUrl: 'https://primary.com/sign-in',
1683+
});
1684+
1685+
expect(requestState).toBeSignedIn({
1686+
domain: 'primary.com',
1687+
isSatellite: false,
1688+
signInUrl: 'https://primary.com/sign-in',
1689+
});
1690+
});
1691+
1692+
test('does not trigger handshake when referer is from dev accounts portal (legacy format)', async () => {
1693+
const request = mockRequestWithCookies(
1694+
{
1695+
referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in',
1696+
'sec-fetch-dest': 'document',
1697+
'sec-fetch-site': 'cross-site',
1698+
},
1699+
{
1700+
__session: mockJwt,
1701+
__client_uat: '12345',
1702+
},
1703+
'https://primary.com/dashboard',
1704+
);
1705+
1706+
const requestState = await authenticateRequest(request, {
1707+
...mockOptions(),
1708+
publishableKey: PK_LIVE,
1709+
domain: 'primary.com',
1710+
isSatellite: false,
1711+
signInUrl: 'https://primary.com/sign-in',
1712+
});
1713+
1714+
expect(requestState).toBeSignedIn({
1715+
domain: 'primary.com',
1716+
isSatellite: false,
1717+
signInUrl: 'https://primary.com/sign-in',
1718+
});
1719+
});
1720+
1721+
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API', async () => {
1722+
const request = mockRequestWithCookies(
1723+
{
1724+
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
1725+
'sec-fetch-dest': 'document',
1726+
'sec-fetch-site': 'cross-site',
1727+
},
1728+
{
1729+
__session: mockJwt,
1730+
__client_uat: '12345',
1731+
},
1732+
'https://primary.com/dashboard',
1733+
);
1734+
1735+
const requestState = await authenticateRequest(request, {
1736+
...mockOptions(),
1737+
domain: 'primary.com',
1738+
isSatellite: false,
1739+
signInUrl: 'https://primary.com/sign-in',
1740+
});
1741+
1742+
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
1743+
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
1744+
});
1745+
1746+
test('does not trigger handshake when referer is from FAPI domain (redirect-based auth)', async () => {
1747+
const request = mockRequestWithCookies(
1748+
{
1749+
referer: 'https://clerk.inspired.puma-74.lcl.dev/v1/client/sign_ins/12345/attempt_first_factor',
1750+
'sec-fetch-dest': 'document',
1751+
'sec-fetch-site': 'cross-site',
1752+
},
1753+
{
1754+
__session: mockJwt,
1755+
__client_uat: '12345',
1756+
},
1757+
'https://primary.com/dashboard',
1758+
);
1759+
1760+
const requestState = await authenticateRequest(request, {
1761+
...mockOptions(),
1762+
domain: 'primary.com',
1763+
isSatellite: false,
1764+
signInUrl: 'https://primary.com/sign-in',
1765+
});
1766+
1767+
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
1768+
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
1769+
});
1770+
1771+
test('does not trigger handshake when referer is from FAPI domain with https prefix', async () => {
1772+
const request = mockRequestWithCookies(
1773+
{
1774+
referer: 'https://clerk.inspired.puma-74.lcl.dev/sign-in',
1775+
'sec-fetch-dest': 'document',
1776+
'sec-fetch-site': 'cross-site',
1777+
},
1778+
{
1779+
__session: mockJwt,
1780+
__client_uat: '12345',
1781+
},
1782+
'https://primary.com/dashboard',
1783+
);
1784+
1785+
const requestState = await authenticateRequest(request, {
1786+
...mockOptions(),
1787+
domain: 'primary.com',
1788+
isSatellite: false,
1789+
signInUrl: 'https://primary.com/sign-in',
1790+
});
1791+
1792+
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
1793+
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
1794+
});
1795+
1796+
test('still triggers handshake for legitimate cross-origin requests from non-accounts domains', async () => {
1797+
const request = mockRequestWithCookies(
1798+
{
1799+
referer: 'https://satellite.com/sign-in',
1800+
'sec-fetch-dest': 'document',
1801+
'sec-fetch-site': 'cross-site',
1802+
},
1803+
{
1804+
__session: mockJwt,
1805+
__client_uat: '12345',
1806+
},
1807+
'https://primary.com/dashboard',
1808+
);
1809+
1810+
const requestState = await authenticateRequest(request, {
1811+
...mockOptions(),
1812+
publishableKey: PK_LIVE,
1813+
domain: 'primary.com',
1814+
isSatellite: false,
1815+
signInUrl: 'https://primary.com/sign-in',
1816+
});
1817+
1818+
expect(requestState).toMatchHandshake({
1819+
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
1820+
domain: 'primary.com',
1821+
signInUrl: 'https://primary.com/sign-in',
1822+
});
1823+
});
1824+
1825+
test('does not trigger handshake when referrer matches current origin despite sec-fetch-site cross-site (redirect chain)', async () => {
1826+
const request = mockRequestWithCookies(
1827+
{
1828+
host: 'primary.com',
1829+
referer: 'https://primary.com/some-page',
1830+
'sec-fetch-dest': 'document',
1831+
'sec-fetch-site': 'cross-site', // This can happen due to redirect chains through Clerk domains
1832+
},
1833+
{
1834+
__session: mockJwt,
1835+
__client_uat: '12345',
1836+
},
1837+
'https://primary.com/dashboard',
1838+
);
1839+
1840+
const requestState = await authenticateRequest(request, {
1841+
...mockOptions(),
1842+
publishableKey: PK_LIVE,
1843+
domain: 'primary.com',
1844+
isSatellite: false,
1845+
signInUrl: 'https://primary.com/sign-in',
1846+
});
1847+
1848+
// Should not trigger handshake because referrer origin matches current origin
1849+
expect(requestState).toBeSignedIn({
1850+
domain: 'primary.com',
1851+
isSatellite: false,
1852+
signInUrl: 'https://primary.com/sign-in',
1853+
});
1854+
});
16081855
});
16091856
});

packages/backend/src/tokens/authenticateContext.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl';
2+
import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url';
13
import type { Jwt } from '@clerk/types';
24

35
import { constants } from '../constants';
@@ -186,10 +188,6 @@ class AuthenticateContext implements AuthenticateContext {
186188
}
187189

188190
try {
189-
if (this.getHeader(constants.Headers.SecFetchSite) === 'cross-site') {
190-
return true;
191-
}
192-
193191
const referrerOrigin = new URL(this.referrer).origin;
194192
return referrerOrigin !== this.clerkUrl.origin;
195193
} catch {
@@ -198,6 +196,56 @@ class AuthenticateContext implements AuthenticateContext {
198196
}
199197
}
200198

199+
/**
200+
* Determines if the referrer URL is from a Clerk domain (accounts portal or FAPI).
201+
* This includes both development and production account portal domains, as well as FAPI domains
202+
* used for redirect-based authentication flows.
203+
*
204+
* @returns {boolean} True if the referrer is from a Clerk accounts portal or FAPI domain, false otherwise
205+
*/
206+
public isKnownClerkReferrer(): boolean {
207+
if (!this.referrer) {
208+
return false;
209+
}
210+
211+
try {
212+
const referrerOrigin = new URL(this.referrer);
213+
const referrerHost = referrerOrigin.hostname;
214+
215+
// Check if referrer is the FAPI domain itself (redirect-based auth flows)
216+
if (this.frontendApi) {
217+
const fapiHost = this.frontendApi.startsWith('http') ? new URL(this.frontendApi).hostname : this.frontendApi;
218+
if (referrerHost === fapiHost) {
219+
return true;
220+
}
221+
}
222+
223+
// Check for development account portal patterns
224+
if (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) {
225+
return true;
226+
}
227+
228+
// Check for production account portal by comparing with expected accounts URL
229+
const expectedAccountsUrl = buildAccountsBaseUrl(this.frontendApi);
230+
if (expectedAccountsUrl) {
231+
const expectedAccountsOrigin = new URL(expectedAccountsUrl).origin;
232+
if (referrerOrigin.origin === expectedAccountsOrigin) {
233+
return true;
234+
}
235+
}
236+
237+
// Check for generic production accounts patterns (accounts.*)
238+
if (referrerHost.startsWith('accounts.')) {
239+
return true;
240+
}
241+
242+
return false;
243+
} catch {
244+
// Invalid URL format
245+
return false;
246+
}
247+
}
248+
201249
private initPublishableKeyValues(options: AuthenticateRequestOptions) {
202250
assertValidPublishableKey(options.publishableKey);
203251
this.publishableKey = options.publishableKey;

packages/backend/src/tokens/request.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,8 @@ export const authenticateRequest: AuthenticateRequest = (async (
576576
const shouldForceHandshakeForCrossDomain =
577577
!authenticateContext.isSatellite && // We're on primary
578578
authenticateContext.secFetchDest === 'document' && // Document navigation
579-
authenticateContext.isCrossOriginReferrer(); // Came from different domain
579+
authenticateContext.isCrossOriginReferrer() && // Came from different domain
580+
!authenticateContext.isKnownClerkReferrer(); // Not from Clerk accounts portal or FAPI
580581

581582
if (shouldForceHandshakeForCrossDomain) {
582583
return handleMaybeHandshakeStatus(

0 commit comments

Comments
 (0)