Skip to content

Commit bd3409e

Browse files
bratsosbrkalowclaude
authored
fix(clerk-js): Prevent session cookie removal during offline token refresh (#7912)
Co-authored-by: brkalow <bryce@clerk.dev> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e7a25e8 commit bd3409e

8 files changed

Lines changed: 338 additions & 31 deletions

File tree

.changeset/three-ads-fold.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Fix random sign-outs when the browser temporarily loses network connectivity.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { appConfigs } from '../presets';
4+
import type { FakeUser } from '../testUtils';
5+
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
6+
7+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })(
8+
'offline session persistence @generic',
9+
({ app }) => {
10+
test.describe.configure({ mode: 'serial' });
11+
12+
let fakeUser: FakeUser;
13+
14+
test.beforeAll(async () => {
15+
const u = createTestUtils({ app });
16+
fakeUser = u.services.users.createFakeUser();
17+
await u.services.users.createBapiUser(fakeUser);
18+
});
19+
20+
test.afterAll(async () => {
21+
await fakeUser.deleteIfExists();
22+
await app.teardown();
23+
});
24+
25+
test('user remains signed in after token endpoint outage and recovery', async ({ page, context }) => {
26+
const u = createTestUtils({ app, page, context });
27+
28+
await u.po.signIn.goTo();
29+
await u.po.signIn.signInWithEmailAndInstantPassword({
30+
email: fakeUser.email,
31+
password: fakeUser.password,
32+
});
33+
await u.po.expect.toBeSignedIn();
34+
35+
const initialToken = await page.evaluate(() => window.Clerk?.session?.getToken());
36+
expect(initialToken).toBeTruthy();
37+
38+
// Simulate token endpoint outage — requests will fail with network error
39+
await page.route('**/v1/client/sessions/*/tokens**', route => route.abort('failed'));
40+
41+
// Clear token cache so any subsequent internal refresh hits the failing endpoint
42+
await page.evaluate(() => window.Clerk?.session?.clearCache());
43+
44+
// eslint-disable-next-line playwright/no-wait-for-timeout
45+
await page.waitForTimeout(3_000);
46+
47+
// Restore network
48+
await page.unrouteAll();
49+
50+
// The session cookie must NOT have been removed during the outage.
51+
// Before the fix, empty tokens would be dispatched to AuthCookieService,
52+
// which interpreted them as sign-out and removed the __session cookie.
53+
await u.po.expect.toBeSignedIn();
54+
55+
// Verify recovery: a fresh token can still be obtained
56+
const recoveredToken = await page.evaluate(() => window.Clerk?.session?.getToken());
57+
expect(recoveredToken).toBeTruthy();
58+
});
59+
60+
test('session survives page reload after token endpoint outage', async ({ page, context }) => {
61+
const u = createTestUtils({ app, page, context });
62+
63+
await u.po.signIn.goTo();
64+
await u.po.signIn.signInWithEmailAndInstantPassword({
65+
email: fakeUser.email,
66+
password: fakeUser.password,
67+
});
68+
await u.po.expect.toBeSignedIn();
69+
70+
// Fail all token refresh requests
71+
await page.route('**/v1/client/sessions/*/tokens**', route => route.abort('failed'));
72+
73+
// Force a refresh attempt that will fail
74+
await page.evaluate(() => window.Clerk?.session?.clearCache());
75+
76+
// eslint-disable-next-line playwright/no-wait-for-timeout
77+
await page.waitForTimeout(2_000);
78+
79+
// Restore network before reload
80+
await page.unrouteAll();
81+
82+
// Reload the page — if the __session cookie was removed during the outage,
83+
// the server would treat this as an unauthenticated request
84+
await page.reload();
85+
await u.po.clerk.toBeLoaded();
86+
87+
await u.po.expect.toBeSignedIn();
88+
});
89+
90+
test('session cookie persists when browser goes fully offline and recovers', async ({ page, context }) => {
91+
const u = createTestUtils({ app, page, context });
92+
93+
await u.po.signIn.goTo();
94+
await u.po.signIn.signInWithEmailAndInstantPassword({
95+
email: fakeUser.email,
96+
password: fakeUser.password,
97+
});
98+
await u.po.expect.toBeSignedIn();
99+
100+
// Go fully offline — sets navigator.onLine to false,
101+
// which triggers the isBrowserOnline() guard in _getToken
102+
await context.setOffline(true);
103+
104+
// Clear token cache while offline
105+
await page.evaluate(() => window.Clerk?.session?.clearCache());
106+
107+
// eslint-disable-next-line playwright/no-wait-for-timeout
108+
await page.waitForTimeout(2_000);
109+
110+
// Come back online
111+
await context.setOffline(false);
112+
113+
// Reload — session cookie must still be intact
114+
await page.reload();
115+
await u.po.clerk.toBeLoaded();
116+
117+
await u.po.expect.toBeSignedIn();
118+
119+
// Confirm a fresh token can be obtained after recovery
120+
const token = await page.evaluate(() => window.Clerk?.session?.getToken());
121+
expect(token).toBeTruthy();
122+
});
123+
},
124+
);

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"files": [
3-
{ "path": "./dist/clerk.js", "maxSize": "539KB" },
3+
{ "path": "./dist/clerk.js", "maxSize": "540KB" },
44
{ "path": "./dist/clerk.browser.js", "maxSize": "66KB" },
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "108KB" },
66
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "307KB" },

packages/clerk-js/src/core/resources/Session.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { createCheckAuthorization } from '@clerk/shared/authorization';
2-
import { isValidBrowserOnline } from '@clerk/shared/browser';
2+
import { isBrowserOnline, isValidBrowserOnline } from '@clerk/shared/browser';
33
import {
44
ClerkOfflineError,
5+
ClerkRuntimeError,
56
ClerkWebAuthnError,
67
is4xxError,
78
is429Error,
@@ -445,18 +446,31 @@ export class Session extends BaseResource implements SessionResource {
445446
// Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates
446447
const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId;
447448

449+
let result: string | null;
450+
448451
if (cacheResult) {
449452
// Proactive refresh is handled by timers scheduled in the cache
450453
// Prefer synchronous read to avoid microtask overhead when token is already resolved
451454
const cachedToken = cacheResult.entry.resolvedToken ?? (await cacheResult.entry.tokenResolver);
452-
if (shouldDispatchTokenUpdate) {
455+
// Only emit token updates when we have an actual token — emitting with an empty
456+
// token causes AuthCookieService to remove the __session cookie (looks like sign-out).
457+
if (shouldDispatchTokenUpdate && cachedToken.getRawString()) {
453458
eventBus.emit(events.TokenUpdate, { token: cachedToken });
454459
}
455-
// Return null when raw string is empty to indicate signed-out state
456-
return cachedToken.getRawString() || null;
460+
result = cachedToken.getRawString() || null;
461+
} else if (!isBrowserOnline()) {
462+
throw new ClerkRuntimeError('Browser is offline, skipping token fetch', { code: 'network_error' });
463+
} else {
464+
result = await this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache);
465+
}
466+
467+
// Throw when offline and no token so retry() in getToken() can fire.
468+
// Without this, _getToken returns null (success) and retry() never calls shouldRetry.
469+
if (result === null && !isValidBrowserOnline()) {
470+
throw new ClerkRuntimeError('Network request failed while offline', { code: 'network_error' });
457471
}
458472

459-
return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache);
473+
return result;
460474
}
461475

462476
#createTokenResolver(
@@ -484,6 +498,12 @@ export class Session extends BaseResource implements SessionResource {
484498
return;
485499
}
486500

501+
// Never dispatch empty tokens — this would cause AuthCookieService to remove
502+
// the __session cookie even though the user is still authenticated.
503+
if (!token.getRawString()) {
504+
return;
505+
}
506+
487507
eventBus.emit(events.TokenUpdate, { token });
488508

489509
if (token.jwt) {
@@ -509,9 +529,14 @@ export class Session extends BaseResource implements SessionResource {
509529
});
510530

511531
return tokenResolver.then(token => {
532+
const rawString = token.getRawString();
533+
if (!rawString) {
534+
// Throw so retry logic in getToken() can handle it,
535+
// rather than silently returning null (which callers interpret as "signed out").
536+
throw new ClerkRuntimeError('Token fetch returned empty response', { code: 'network_error' });
537+
}
512538
this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate);
513-
// Return null when raw string is empty to indicate signed-out state
514-
return token.getRawString() || null;
539+
return rawString;
515540
});
516541
}
517542

@@ -541,6 +566,12 @@ export class Session extends BaseResource implements SessionResource {
541566
// This allows concurrent calls to continue using the stale token
542567
tokenResolver
543568
.then(token => {
569+
// Never cache or dispatch empty tokens — preserve the stale-but-valid
570+
// token in cache instead of replacing it with an empty one.
571+
if (!token.getRawString()) {
572+
return;
573+
}
574+
544575
// Cache the resolved token for future calls
545576
// Re-register onRefresh to handle the next refresh cycle when this token approaches expiration
546577
SessionTokenCache.set({

0 commit comments

Comments
 (0)