Skip to content

Commit de9c01a

Browse files
brkalowjacekradko
andauthored
feat(backend): Handles cross origin syncing on primary domains (#6238)
Co-authored-by: Jacek <[email protected]>
1 parent c71d98b commit de9c01a

File tree

6 files changed

+229
-0
lines changed

6 files changed

+229
-0
lines changed

.changeset/orange-bikes-prove.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': minor
3+
---
4+
5+
Trigger a handshake on a signed in, cross origin request to sync session state from a satellite domain.

packages/backend/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const Headers = {
6464
Origin: 'origin',
6565
Referrer: 'referer',
6666
SecFetchDest: 'sec-fetch-dest',
67+
SecFetchSite: 'sec-fetch-site',
6768
UserAgent: 'user-agent',
6869
ReportingEndpoints: 'reporting-endpoints',
6970
} as const;

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

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,4 +1396,188 @@ describe('tokens.authenticateRequest(options)', () => {
13961396
});
13971397
});
13981398
});
1399+
1400+
describe('Cross-origin sync', () => {
1401+
beforeEach(() => {
1402+
server.use(
1403+
http.get('https://api.clerk.test/v1/jwks', () => {
1404+
return HttpResponse.json(mockJwks);
1405+
}),
1406+
);
1407+
});
1408+
1409+
test('triggers handshake for cross-origin document request on primary domain', async () => {
1410+
const request = mockRequestWithCookies(
1411+
{
1412+
referer: 'https://satellite.com/signin',
1413+
'sec-fetch-dest': 'document',
1414+
origin: 'https://primary.com',
1415+
},
1416+
{
1417+
__session: mockJwt,
1418+
__client_uat: '12345',
1419+
},
1420+
'https://primary.com/dashboard',
1421+
);
1422+
1423+
const requestState = await authenticateRequest(request, {
1424+
...mockOptions(),
1425+
publishableKey: PK_LIVE,
1426+
domain: 'primary.com',
1427+
isSatellite: false,
1428+
signInUrl: 'https://primary.com/sign-in',
1429+
});
1430+
1431+
expect(requestState).toMatchHandshake({
1432+
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
1433+
domain: 'primary.com',
1434+
signInUrl: 'https://primary.com/sign-in',
1435+
});
1436+
});
1437+
1438+
test('triggers handshake for cross-site document request on primary domain', async () => {
1439+
const request = mockRequestWithCookies(
1440+
{
1441+
referer: 'https://satellite.com/signin',
1442+
'sec-fetch-dest': 'document',
1443+
'sec-fetch-site': 'cross-site',
1444+
origin: 'https://primary.com',
1445+
},
1446+
{
1447+
__session: mockJwt,
1448+
__client_uat: '12345',
1449+
},
1450+
'https://primary.com/dashboard',
1451+
);
1452+
1453+
const requestState = await authenticateRequest(request, {
1454+
...mockOptions(),
1455+
publishableKey: PK_LIVE,
1456+
domain: 'primary.com',
1457+
isSatellite: false,
1458+
signInUrl: 'https://primary.com/sign-in',
1459+
});
1460+
1461+
expect(requestState).toMatchHandshake({
1462+
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
1463+
domain: 'primary.com',
1464+
signInUrl: 'https://primary.com/sign-in',
1465+
});
1466+
});
1467+
1468+
test('does not trigger handshake when referer is same origin', async () => {
1469+
const request = mockRequestWithCookies(
1470+
{
1471+
referer: 'https://primary.com/signin',
1472+
'sec-fetch-dest': 'document',
1473+
origin: 'https://primary.com',
1474+
},
1475+
{
1476+
__session: mockJwt,
1477+
__client_uat: '12345',
1478+
},
1479+
'https://primary.com/dashboard',
1480+
);
1481+
1482+
const requestState = await authenticateRequest(request, {
1483+
...mockOptions(),
1484+
publishableKey: PK_LIVE,
1485+
domain: 'primary.com',
1486+
isSatellite: false,
1487+
signInUrl: 'https://primary.com/sign-in',
1488+
});
1489+
1490+
expect(requestState).toBeSignedIn({
1491+
domain: 'primary.com',
1492+
isSatellite: false,
1493+
signInUrl: 'https://primary.com/sign-in',
1494+
});
1495+
});
1496+
1497+
test('does not trigger handshake when no referer header', async () => {
1498+
const request = mockRequestWithCookies(
1499+
{
1500+
'sec-fetch-dest': 'document',
1501+
origin: 'https://primary.com',
1502+
},
1503+
{
1504+
__session: mockJwt,
1505+
__client_uat: '12345',
1506+
},
1507+
'https://primary.com/dashboard',
1508+
);
1509+
1510+
const requestState = await authenticateRequest(request, {
1511+
...mockOptions(),
1512+
publishableKey: PK_LIVE,
1513+
domain: 'primary.com',
1514+
isSatellite: false,
1515+
signInUrl: 'https://primary.com/sign-in',
1516+
});
1517+
1518+
expect(requestState).toBeSignedIn({
1519+
domain: 'primary.com',
1520+
isSatellite: false,
1521+
signInUrl: 'https://primary.com/sign-in',
1522+
});
1523+
});
1524+
1525+
test('does not trigger handshake for non-document requests', async () => {
1526+
const request = mockRequestWithCookies(
1527+
{
1528+
referer: 'https://satellite.com/signin',
1529+
'sec-fetch-dest': 'empty',
1530+
origin: 'https://primary.com',
1531+
},
1532+
{
1533+
__session: mockJwt,
1534+
__client_uat: '12345',
1535+
},
1536+
'https://primary.com/api/data',
1537+
);
1538+
1539+
const requestState = await authenticateRequest(request, {
1540+
...mockOptions(),
1541+
publishableKey: PK_LIVE,
1542+
domain: 'primary.com',
1543+
isSatellite: false,
1544+
signInUrl: 'https://primary.com/sign-in',
1545+
});
1546+
1547+
expect(requestState).toBeSignedIn({
1548+
domain: 'primary.com',
1549+
isSatellite: false,
1550+
signInUrl: 'https://primary.com/sign-in',
1551+
});
1552+
});
1553+
1554+
test('does not trigger handshake when referer header contains invalid URL format', async () => {
1555+
const request = mockRequestWithCookies(
1556+
{
1557+
referer: 'invalid-url-format',
1558+
'sec-fetch-dest': 'document',
1559+
origin: 'https://primary.com',
1560+
},
1561+
{
1562+
__session: mockJwt,
1563+
__client_uat: '12345',
1564+
},
1565+
'https://primary.com/dashboard',
1566+
);
1567+
1568+
const requestState = await authenticateRequest(request, {
1569+
...mockOptions(),
1570+
publishableKey: PK_LIVE,
1571+
domain: 'primary.com',
1572+
isSatellite: false,
1573+
signInUrl: 'https://primary.com/sign-in',
1574+
});
1575+
1576+
expect(requestState).toBeSignedIn({
1577+
domain: 'primary.com',
1578+
isSatellite: false,
1579+
signInUrl: 'https://primary.com/sign-in',
1580+
});
1581+
});
1582+
});
13991583
});

packages/backend/src/tokens/authStatus.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export const AuthErrorReason = {
107107
DevBrowserMissing: 'dev-browser-missing',
108108
DevBrowserSync: 'dev-browser-sync',
109109
PrimaryRespondsToSyncing: 'primary-responds-to-syncing',
110+
PrimaryDomainCrossOriginSync: 'primary-domain-cross-origin-sync',
110111
SatelliteCookieNeedsSyncing: 'satellite-needs-syncing',
111112
SessionTokenAndUATMissing: 'session-token-and-uat-missing',
112113
SessionTokenMissing: 'session-token-missing',

packages/backend/src/tokens/authenticateContext.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,30 @@ class AuthenticateContext implements AuthenticateContext {
167167
return true;
168168
}
169169

170+
/**
171+
* Determines if the request came from a different origin based on the referrer header.
172+
* Used for cross-origin detection in multi-domain authentication flows.
173+
*
174+
* @returns {boolean} True if referrer exists and is from a different origin, false otherwise.
175+
*/
176+
public isCrossOriginReferrer(): boolean {
177+
if (!this.referrer || !this.origin) {
178+
return false;
179+
}
180+
181+
try {
182+
if (this.getHeader(constants.Headers.SecFetchSite) === 'cross-site') {
183+
return true;
184+
}
185+
186+
const referrerOrigin = new URL(this.referrer).origin;
187+
return referrerOrigin !== this.origin;
188+
} catch {
189+
// Invalid referrer URL format
190+
return false;
191+
}
192+
}
193+
170194
private initPublishableKeyValues(options: AuthenticateRequestOptions) {
171195
assertValidPublishableKey(options.publishableKey);
172196
this.publishableKey = options.publishableKey;

packages/backend/src/tokens/request.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,20 @@ export const authenticateRequest: AuthenticateRequest = (async (
553553
token: authenticateContext.sessionTokenInCookie!,
554554
});
555555

556+
// Check for cross-origin requests from satellite domains to primary domain
557+
const shouldForceHandshakeForCrossDomain =
558+
!authenticateContext.isSatellite && // We're on primary
559+
authenticateContext.secFetchDest === 'document' && // Document navigation
560+
authenticateContext.isCrossOriginReferrer(); // Came from different domain
561+
562+
if (shouldForceHandshakeForCrossDomain) {
563+
return handleMaybeHandshakeStatus(
564+
authenticateContext,
565+
AuthErrorReason.PrimaryDomainCrossOriginSync,
566+
'Cross-origin request from satellite domain requires handshake',
567+
);
568+
}
569+
556570
const authObject = signedInRequestState.toAuth();
557571
// Org sync if necessary
558572
if (authObject.userId) {

0 commit comments

Comments
 (0)