Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/orange-bikes-prove.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
184 changes: 184 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});
});
});
1 change: 1 addition & 0 deletions packages/backend/src/tokens/authStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
24 changes: 24 additions & 0 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down