Skip to content

Commit ffde23d

Browse files
committed
Update logic for cross origin sync handshake case to ignore known auth referers
1 parent 9f0aad2 commit ffde23d

File tree

4 files changed

+250
-1
lines changed

4 files changed

+250
-1
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: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1605,5 +1605,196 @@ describe('tokens.authenticateRequest(options)', () => {
16051605
signInUrl: 'https://primary.com/sign-in',
16061606
});
16071607
});
1608+
1609+
test('does not trigger handshake when referer is from production accounts portal', async () => {
1610+
const request = mockRequestWithCookies(
1611+
{
1612+
referer: 'https://accounts.example.com/sign-in',
1613+
'sec-fetch-dest': 'document',
1614+
'sec-fetch-site': 'cross-site',
1615+
},
1616+
{
1617+
__session: mockJwt,
1618+
__client_uat: '12345',
1619+
},
1620+
'https://primary.com/dashboard',
1621+
);
1622+
1623+
const requestState = await authenticateRequest(request, {
1624+
...mockOptions(),
1625+
publishableKey: PK_LIVE,
1626+
domain: 'primary.com',
1627+
isSatellite: false,
1628+
signInUrl: 'https://primary.com/sign-in',
1629+
});
1630+
1631+
expect(requestState).toBeSignedIn({
1632+
domain: 'primary.com',
1633+
isSatellite: false,
1634+
signInUrl: 'https://primary.com/sign-in',
1635+
});
1636+
});
1637+
1638+
test('does not trigger handshake when referer is from dev accounts portal (current format)', async () => {
1639+
const request = mockRequestWithCookies(
1640+
{
1641+
referer: 'https://foo-bar-13.accounts.dev/sign-in',
1642+
'sec-fetch-dest': 'document',
1643+
'sec-fetch-site': 'cross-site',
1644+
},
1645+
{
1646+
__session: mockJwt,
1647+
__client_uat: '12345',
1648+
},
1649+
'https://primary.com/dashboard',
1650+
);
1651+
1652+
const requestState = await authenticateRequest(request, {
1653+
...mockOptions(),
1654+
publishableKey: PK_LIVE,
1655+
domain: 'primary.com',
1656+
isSatellite: false,
1657+
signInUrl: 'https://primary.com/sign-in',
1658+
});
1659+
1660+
expect(requestState).toBeSignedIn({
1661+
domain: 'primary.com',
1662+
isSatellite: false,
1663+
signInUrl: 'https://primary.com/sign-in',
1664+
});
1665+
});
1666+
1667+
test('does not trigger handshake when referer is from dev accounts portal (legacy format)', async () => {
1668+
const request = mockRequestWithCookies(
1669+
{
1670+
referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in',
1671+
'sec-fetch-dest': 'document',
1672+
'sec-fetch-site': 'cross-site',
1673+
},
1674+
{
1675+
__session: mockJwt,
1676+
__client_uat: '12345',
1677+
},
1678+
'https://primary.com/dashboard',
1679+
);
1680+
1681+
const requestState = await authenticateRequest(request, {
1682+
...mockOptions(),
1683+
publishableKey: PK_LIVE,
1684+
domain: 'primary.com',
1685+
isSatellite: false,
1686+
signInUrl: 'https://primary.com/sign-in',
1687+
});
1688+
1689+
expect(requestState).toBeSignedIn({
1690+
domain: 'primary.com',
1691+
isSatellite: false,
1692+
signInUrl: 'https://primary.com/sign-in',
1693+
});
1694+
});
1695+
1696+
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API', async () => {
1697+
const request = mockRequestWithCookies(
1698+
{
1699+
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
1700+
'sec-fetch-dest': 'document',
1701+
'sec-fetch-site': 'cross-site',
1702+
},
1703+
{
1704+
__session: mockJwt,
1705+
__client_uat: '12345',
1706+
},
1707+
'https://primary.com/dashboard',
1708+
);
1709+
1710+
const requestState = await authenticateRequest(request, {
1711+
...mockOptions(),
1712+
domain: 'primary.com',
1713+
isSatellite: false,
1714+
signInUrl: 'https://primary.com/sign-in',
1715+
});
1716+
1717+
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
1718+
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
1719+
});
1720+
1721+
test('does not trigger handshake when referer is from FAPI domain (redirect-based auth)', async () => {
1722+
const request = mockRequestWithCookies(
1723+
{
1724+
referer: 'https://clerk.inspired.puma-74.lcl.dev/v1/client/sign_ins/12345/attempt_first_factor',
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 with https prefix', async () => {
1747+
const request = mockRequestWithCookies(
1748+
{
1749+
referer: 'https://clerk.inspired.puma-74.lcl.dev/sign-in',
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('still triggers handshake for legitimate cross-origin requests from non-accounts domains', async () => {
1772+
const request = mockRequestWithCookies(
1773+
{
1774+
referer: 'https://satellite.com/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+
publishableKey: PK_LIVE,
1788+
domain: 'primary.com',
1789+
isSatellite: false,
1790+
signInUrl: 'https://primary.com/sign-in',
1791+
});
1792+
1793+
expect(requestState).toMatchHandshake({
1794+
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
1795+
domain: 'primary.com',
1796+
signInUrl: 'https://primary.com/sign-in',
1797+
});
1798+
});
16081799
});
16091800
});

packages/backend/src/tokens/authenticateContext.ts

Lines changed: 52 additions & 0 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';
@@ -198,6 +200,56 @@ class AuthenticateContext implements AuthenticateContext {
198200
}
199201
}
200202

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

581582
if (shouldForceHandshakeForCrossDomain) {
582583
return handleMaybeHandshakeStatus(

0 commit comments

Comments
 (0)