Skip to content

Commit 992e596

Browse files
nikosdouvlisdimkl
andauthored
chore(repo): Retry handshake in case of handshake cookie collision (#3848)
Co-authored-by: Dimitris Klouvas <dimitris@clerk.dev>
1 parent 65cf479 commit 992e596

File tree

7 files changed

+137
-28
lines changed

7 files changed

+137
-28
lines changed

.changeset/sixty-mugs-clap.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+
Retry handshake in case of handshake cookie collision in order to support multiple apps on same-level subdomains

integration/tests/sessions/root-subdomain-prod-instances.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,4 +302,77 @@ test.describe('root and subdomain production apps @sessions', () => {
302302
await u[1].po.expect.toBeSignedIn();
303303
});
304304
});
305+
306+
/**
307+
* This smoke test verifies that the session is not shared between different apps running on different same-level subdomains, while
308+
* using different Clerk instances. For extra details, look at the previous test.
309+
*
310+
* sub1.test.com <> clerk-instance-1
311+
* sub2.test.com <> clerk-instance-2
312+
*
313+
*/
314+
test.describe('multiple apps different same-level subdomains for different production instances', () => {
315+
const hosts = ['sub-1.multiple-apps-e2e.clerk.app', 'sub-2.multiple-apps-e2e.clerk.app'];
316+
let fakeUsers: FakeUser[];
317+
let server: Server;
318+
let apps: Array<{ app: Application; serverUrl: string }>;
319+
320+
test.beforeAll(async () => {
321+
apps = await Promise.all([prepareApplication('sessions-prod-1'), prepareApplication('sessions-prod-2')]);
322+
323+
// TODO: Move this into createProxyServer
324+
const ssl: Pick<ServerOptions, 'ca' | 'cert' | 'key'> = {
325+
cert: fs.readFileSync(constants.CERTS_DIR + '/sessions.pem'),
326+
key: fs.readFileSync(constants.CERTS_DIR + '/sessions-key.pem'),
327+
};
328+
329+
server = createProxyServer({
330+
ssl,
331+
targets: {
332+
[hosts[0]]: apps[0].serverUrl,
333+
[hosts[1]]: apps[1].serverUrl,
334+
},
335+
});
336+
337+
const u = apps.map(a => createTestUtils({ app: a.app }));
338+
fakeUsers = await Promise.all(u.map(u => u.services.users.createFakeUser()));
339+
await Promise.all([
340+
await u[0].services.users.createBapiUser(fakeUsers[0]),
341+
await u[1].services.users.createBapiUser(fakeUsers[1]),
342+
]);
343+
});
344+
345+
test.afterAll(async () => {
346+
await Promise.all(fakeUsers.map(u => u.deleteIfExists()));
347+
await Promise.all(apps.map(({ app }) => app.teardown()));
348+
server.close();
349+
});
350+
351+
test('the cookies are independent for the root and sub domains', async ({ context }) => {
352+
const pages = await Promise.all([context.newPage(), context.newPage()]);
353+
const u = [
354+
createTestUtils({ app: apps[0].app, page: pages[0], context }),
355+
createTestUtils({ app: apps[1].app, page: pages[1], context }),
356+
];
357+
358+
await u[0].page.goto(`https://${hosts[0]}`);
359+
await u[0].po.signIn.goTo();
360+
await u[0].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[0]);
361+
await u[0].po.expect.toBeSignedIn();
362+
const tab0User = await u[0].po.clerk.getClientSideUser();
363+
// make sure that the backend user now matches the user we signed in with on the client
364+
expect((await u[0].page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe(tab0User.id);
365+
366+
u[1].po.expect.toBeHandshake(await u[1].page.goto(`https://${hosts[1]}`));
367+
await u[1].po.expect.toBeSignedOut();
368+
expect((await u[1].page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe(null);
369+
370+
await u[1].po.signIn.goTo();
371+
await u[1].po.signIn.signInWithEmailAndInstantPassword(fakeUsers[1]);
372+
await u[1].po.expect.toBeSignedIn();
373+
const tab1User = await u[1].po.clerk.getClientSideUser();
374+
// make sure that the backend user now matches the user we signed in with on the client
375+
expect((await u[1].page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe(tab1User.id);
376+
});
377+
});
305378
});

packages/backend/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const Cookies = {
1919
ClientUat: '__client_uat',
2020
Handshake: '__clerk_handshake',
2121
DevBrowser: '__clerk_db_jwt',
22+
RedirectCount: '__clerk_redirect_count',
2223
} as const;
2324

2425
const QueryParameters = {

packages/backend/src/tokens/authenticateContext.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface AuthenticateContextInterface extends AuthenticateRequestOptions {
2626
// handshake-related values
2727
devBrowserToken: string | undefined;
2828
handshakeToken: string | undefined;
29+
handshakeRedirectLoopCounter: number;
2930
// url derived from headers
3031
clerkUrl: URL;
3132
// cookie or header session token
@@ -106,6 +107,7 @@ class AuthenticateContext {
106107
// Using getCookie since we don't suffix the handshake token cookie
107108
this.handshakeToken =
108109
this.getQueryParam(constants.QueryParameters.Handshake) || this.getCookie(constants.Cookies.Handshake);
110+
this.handshakeRedirectLoopCounter = Number(this.getCookie(constants.Cookies.RedirectCount)) || 0;
109111
}
110112

111113
private stripAuthorizationHeader(authValue: string | undefined | null): string | undefined {

packages/backend/src/tokens/handshake.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ async function verifyHandshakeJwt(token: string, { key }: VerifyJwtOptions): Pro
3838
}
3939

4040
/**
41-
* Similar to our verifyToken flow for Clerk-issued JWTs, but this verification flow is for our signed handshake payload. The handshake payload requires fewer verification steps.
41+
* Similar to our verifyToken flow for Clerk-issued JWTs, but this verification flow is for our signed handshake payload.
42+
* The handshake payload requires fewer verification steps.
4243
*/
4344
export async function verifyHandshakeToken(
4445
token: string,

packages/backend/src/tokens/request.ts

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,19 @@ ${error.getFullMessage()}`,
173173
if (isRequestEligibleForHandshake(authenticateContext)) {
174174
// Right now the only usage of passing in different headers is for multi-domain sync, which redirects somewhere else.
175175
// In the future if we want to decorate the handshake redirect with additional headers per call we need to tweak this logic.
176-
return handshake(authenticateContext, reason, message, headers ?? buildRedirectToHandshake());
176+
const handshakeHeaders = headers ?? buildRedirectToHandshake();
177+
// Introduce the mechanism to protect for infinite handshake redirect loops
178+
// using a cookie and returning true if it's infinite redirect loop or false if we can
179+
// proceed with triggering handshake.
180+
const isRedirectLoop = setHandshakeInfiniteRedirectionLoopHeaders(handshakeHeaders);
181+
if (isRedirectLoop) {
182+
const msg = `Clerk: Refreshing the session token resulted in an infinite redirect loop. This usually means that your Clerk instance keys do not match - make sure to copy the correct publishable and secret keys from the Clerk dashboard.`;
183+
console.log(msg);
184+
return signedOut(authenticateContext, reason, message);
185+
}
186+
return handshake(authenticateContext, reason, message, handshakeHeaders);
177187
}
178-
return signedOut(authenticateContext, reason, message, new Headers());
188+
return signedOut(authenticateContext, reason, message);
179189
}
180190

181191
async function authenticateRequestWithTokenInHeader() {
@@ -193,6 +203,34 @@ ${error.getFullMessage()}`,
193203
}
194204
}
195205

206+
// We want to prevent infinite handshake redirection loops.
207+
// We incrementally set a `__clerk_redirection_loop` cookie, and when it loops 3 times, we throw an error.
208+
// We also utilize the `referer` header to skip the prefetch requests.
209+
function setHandshakeInfiniteRedirectionLoopHeaders(headers: Headers): boolean {
210+
if (authenticateContext.handshakeRedirectLoopCounter === 3) {
211+
return true;
212+
}
213+
214+
const newCounterValue = authenticateContext.handshakeRedirectLoopCounter + 1;
215+
const cookieName = constants.Cookies.RedirectCount;
216+
headers.append('Set-Cookie', `${cookieName}=${newCounterValue}; SameSite=Lax; HttpOnly; Max-Age=3`);
217+
return false;
218+
}
219+
220+
function handleHandshakeTokenVerificationErrorInDevelopment(error: TokenVerificationError) {
221+
// In development, the handshake token is being transferred in the URL as a query parameter, so there is no
222+
// possibility of collision with a handshake token of another app running on the same local domain
223+
// (etc one app on localhost:3000 and one on localhost:3001).
224+
// Therefore, if the handshake token is invalid, it is likely that the user has switched Clerk keys locally.
225+
// We make sure to throw a descriptive error message and then stop the handshake flow in every case,
226+
// to avoid the possibility of an infinite loop.
227+
if (error.reason === TokenVerificationErrorReason.TokenInvalidSignature) {
228+
const msg = `Clerk: Handshake token verification failed due to an invalid signature. If you have switched Clerk keys locally, clear your cookies and try again.`;
229+
throw new Error(msg);
230+
}
231+
throw new Error(`Clerk: Handshake token verification failed: ${error.getFullMessage()}.`);
232+
}
233+
196234
async function authenticateRequestWithTokenInCookie() {
197235
const hasActiveClient = authenticateContext.clientUat;
198236
const hasSessionToken = !!authenticateContext.sessionTokenInCookie;
@@ -210,34 +248,22 @@ ${error.getFullMessage()}`,
210248
try {
211249
return await resolveHandshake();
212250
} catch (error) {
213-
// If for some reason the handshake token is invalid or stale, we ignore it and continue trying to authenticate the request.
214-
// Worst case, the handshake will trigger again and return a refreshed token.
215-
if (error instanceof TokenVerificationError) {
216-
if (authenticateContext.instanceType === 'development') {
217-
if (error.reason === TokenVerificationErrorReason.TokenInvalidSignature) {
218-
throw new Error(
219-
`Clerk: Handshake token verification failed due to an invalid signature. If you have switched Clerk keys locally, clear your cookies and try again.`,
220-
);
221-
}
222-
223-
throw new Error(`Clerk: Handshake token verification failed: ${error.getFullMessage()}.`);
224-
}
225-
226-
if (
227-
error.reason === TokenVerificationErrorReason.TokenInvalidSignature ||
228-
error.reason === TokenVerificationErrorReason.InvalidSecretKey
229-
) {
230-
// Avoid infinite redirect loops due to incorrect secret-keys
231-
return signedOut(
232-
authenticateContext,
233-
AuthErrorReason.UnexpectedError,
234-
`Clerk: Handshake token verification failed with "${error.reason}"`,
235-
);
236-
}
251+
// In production, the handshake token is being transferred as a cookie, so there is a possibility of collision
252+
// with a handshake token of another app running on the same etld+1 domain.
253+
// For example, if one app is running on sub1.clerk.com and another on sub2.clerk.com, the handshake token
254+
// cookie for both apps will be set on etld+1 (clerk.com) so there's a possibility that one app will accidentally
255+
// use the handshake token of a different app during the handshake flow.
256+
// In this scenario, verification will fail with TokenInvalidSignature. In contrast to the development case,
257+
// we need to allow the flow to continue so the app eventually retries another handshake with the correct token.
258+
// We need to make sure, however, that we don't allow the flow to continue indefinitely, so we throw an error after X
259+
// retries to avoid an infinite loop. An infinite loop can happen if the customer switched Clerk keys for their prod app.
260+
261+
// Check the handleHandshakeTokenVerificationErrorInDevelopment function for the development case.
262+
if (error instanceof TokenVerificationError && authenticateContext.instanceType === 'development') {
263+
handleHandshakeTokenVerificationErrorInDevelopment(error);
237264
}
238265
}
239266
}
240-
241267
/**
242268
* Otherwise, check for "known unknown" auth states that we can resolve with a handshake.
243269
*/

packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ exports[`constants from environment variables 1`] = `
88
"ClientUat": "__client_uat",
99
"DevBrowser": "__clerk_db_jwt",
1010
"Handshake": "__clerk_handshake",
11+
"RedirectCount": "__clerk_redirect_count",
1112
"Session": "__session",
1213
},
1314
"Headers": {

0 commit comments

Comments
 (0)