Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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.
157 changes: 156 additions & 1 deletion packages/backend/src/tokens/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,8 @@ expect.extend({
};
} else {
return {
message: () => `expected to be signed in, but got ${received.status}`,
message: () =>
`expected to be signed in, but got ${received.status}`,
pass: false,
};
}
Expand Down Expand Up @@ -1396,4 +1397,158 @@ 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('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
20 changes: 20 additions & 0 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,26 @@ 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 {
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
Loading