Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 469
fix(clerk-js): Prevent session cookie removal during offline token refresh#7912
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
brkalow
merged 9 commits into
main
from
alexbratsos/user-4744-investigate-random-sign-outs-in-the-dashboard-possiblyMar 9, 2026
Uh oh!
There was an error while loading. Please reload this page.
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
618871a
fix(clerk-js): Prevent session cookie removal during offline token re…
bratsos be6256a
fixup! fix(clerk-js): Prevent session cookie removal during offline t…
bratsos 4c86bb9
fixup! fix(clerk-js): Prevent session cookie removal during offline t…
bratsos 2c9a84e
fixup! fix(clerk-js): Prevent session cookie removal during offline t…
bratsos 2c09e1e
fixup! fix(clerk-js): Prevent session cookie removal during offline t…
bratsos 58fbac9
refactor(clerk-js): Simplify _getToken control flow and trim comment
brkalow 16e98bf
test: Add integration tests for offline session persistence
brkalow 56d07ec
Merge branch 'main' into alexbratsos/user-4744-investigate-random-sig…
brkalow 6c97ffd
fix(repo): Mock base @formkit/auto-animate module in vitest setup
brkalow File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Jump to file
Failed to load files.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@clerk/clerk-js': patch | ||
| --- | ||
| Fix random sign-outs when the browser temporarily loses network connectivity. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { appConfigs } from '../presets'; | ||
| import type { FakeUser } from '../testUtils'; | ||
| import { createTestUtils, testAgainstRunningApps } from '../testUtils'; | ||
| testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( | ||
| 'offline session persistence @generic', | ||
| ({ app }) => { | ||
| test.describe.configure({ mode: 'serial' }); | ||
| let fakeUser: FakeUser; | ||
| test.beforeAll(async () => { | ||
| const u = createTestUtils({ app }); | ||
| fakeUser = u.services.users.createFakeUser(); | ||
| await u.services.users.createBapiUser(fakeUser); | ||
| }); | ||
| test.afterAll(async () => { | ||
| await fakeUser.deleteIfExists(); | ||
| await app.teardown(); | ||
| }); | ||
| test('user remains signed in after token endpoint outage and recovery', async ({ page, context }) => { | ||
| const u = createTestUtils({ app, page, context }); | ||
| await u.po.signIn.goTo(); | ||
| await u.po.signIn.signInWithEmailAndInstantPassword({ | ||
| email: fakeUser.email, | ||
| password: fakeUser.password, | ||
| }); | ||
| await u.po.expect.toBeSignedIn(); | ||
| const initialToken = await page.evaluate(() => window.Clerk?.session?.getToken()); | ||
| expect(initialToken).toBeTruthy(); | ||
| // Simulate token endpoint outage — requests will fail with network error | ||
| await page.route('**/v1/client/sessions/*/tokens**', route => route.abort('failed')); | ||
| // Clear token cache so any subsequent internal refresh hits the failing endpoint | ||
| await page.evaluate(() => window.Clerk?.session?.clearCache()); | ||
| // eslint-disable-next-line playwright/no-wait-for-timeout | ||
| await page.waitForTimeout(3_000); | ||
| // Restore network | ||
| await page.unrouteAll(); | ||
| // The session cookie must NOT have been removed during the outage. | ||
| // Before the fix, empty tokens would be dispatched to AuthCookieService, | ||
| // which interpreted them as sign-out and removed the __session cookie. | ||
| await u.po.expect.toBeSignedIn(); | ||
| // Verify recovery: a fresh token can still be obtained | ||
| const recoveredToken = await page.evaluate(() => window.Clerk?.session?.getToken()); | ||
| expect(recoveredToken).toBeTruthy(); | ||
| }); | ||
| test('session survives page reload after token endpoint outage', async ({ page, context }) => { | ||
| const u = createTestUtils({ app, page, context }); | ||
| await u.po.signIn.goTo(); | ||
| await u.po.signIn.signInWithEmailAndInstantPassword({ | ||
| email: fakeUser.email, | ||
| password: fakeUser.password, | ||
| }); | ||
| await u.po.expect.toBeSignedIn(); | ||
| // Fail all token refresh requests | ||
| await page.route('**/v1/client/sessions/*/tokens**', route => route.abort('failed')); | ||
| // Force a refresh attempt that will fail | ||
| await page.evaluate(() => window.Clerk?.session?.clearCache()); | ||
| // eslint-disable-next-line playwright/no-wait-for-timeout | ||
| await page.waitForTimeout(2_000); | ||
| // Restore network before reload | ||
| await page.unrouteAll(); | ||
| // Reload the page — if the __session cookie was removed during the outage, | ||
| // the server would treat this as an unauthenticated request | ||
| await page.reload(); | ||
| await u.po.clerk.toBeLoaded(); | ||
| await u.po.expect.toBeSignedIn(); | ||
| }); | ||
| test('session cookie persists when browser goes fully offline and recovers', async ({ page, context }) => { | ||
| const u = createTestUtils({ app, page, context }); | ||
| await u.po.signIn.goTo(); | ||
| await u.po.signIn.signInWithEmailAndInstantPassword({ | ||
| email: fakeUser.email, | ||
| password: fakeUser.password, | ||
| }); | ||
| await u.po.expect.toBeSignedIn(); | ||
| // Go fully offline — sets navigator.onLine to false, | ||
| // which triggers the isBrowserOnline() guard in _getToken | ||
| await context.setOffline(true); | ||
| // Clear token cache while offline | ||
| await page.evaluate(() => window.Clerk?.session?.clearCache()); | ||
| // eslint-disable-next-line playwright/no-wait-for-timeout | ||
| await page.waitForTimeout(2_000); | ||
| // Come back online | ||
| await context.setOffline(false); | ||
| // Reload — session cookie must still be intact | ||
| await page.reload(); | ||
| await u.po.clerk.toBeLoaded(); | ||
| await u.po.expect.toBeSignedIn(); | ||
| // Confirm a fresh token can be obtained after recovery | ||
| const token = await page.evaluate(() => window.Clerk?.session?.getToken()); | ||
| expect(token).toBeTruthy(); | ||
| }); | ||
| }, | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,8 @@ | ||
| import { createCheckAuthorization } from '@clerk/shared/authorization'; | ||
| import { isValidBrowserOnline } from '@clerk/shared/browser'; | ||
| import { isBrowserOnline, isValidBrowserOnline } from '@clerk/shared/browser'; | ||
| import { | ||
| ClerkOfflineError, | ||
| ClerkRuntimeError, | ||
| ClerkWebAuthnError, | ||
| is4xxError, | ||
| is429Error, | ||
| @@ -445,18 +446,31 @@ export class Session extends BaseResource implements SessionResource { | ||
| // Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates | ||
| const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; | ||
| let result: string | null; | ||
| if (cacheResult) { | ||
| // Proactive refresh is handled by timers scheduled in the cache | ||
| // Prefer synchronous read to avoid microtask overhead when token is already resolved | ||
| const cachedToken = cacheResult.entry.resolvedToken ?? (await cacheResult.entry.tokenResolver); | ||
| if (shouldDispatchTokenUpdate) { | ||
| // Only emit token updates when we have an actual token — emitting with an empty | ||
| // token causes AuthCookieService to remove the __session cookie (looks like sign-out). | ||
| if (shouldDispatchTokenUpdate && cachedToken.getRawString()) { | ||
| eventBus.emit(events.TokenUpdate, { token: cachedToken }); | ||
| } | ||
| // Return null when raw string is empty to indicate signed-out state | ||
| return cachedToken.getRawString() || null; | ||
| result = cachedToken.getRawString() || null; | ||
| } else if (!isBrowserOnline()) { | ||
| throw new ClerkRuntimeError('Browser is offline, skipping token fetch', { code: 'network_error' }); | ||
| } else { | ||
| result = await this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); | ||
| } | ||
| // Throw when offline and no token so retry() in getToken() can fire. | ||
| // Without this, _getToken returns null (success) and retry() never calls shouldRetry. | ||
| if (result === null && !isValidBrowserOnline()) { | ||
| throw new ClerkRuntimeError('Network request failed while offline', { code: 'network_error' }); | ||
brkalow marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); | ||
| return result; | ||
| } | ||
| #createTokenResolver( | ||
| @@ -484,6 +498,12 @@ export class Session extends BaseResource implements SessionResource { | ||
| return; | ||
| } | ||
| // Never dispatch empty tokens — this would cause AuthCookieService to remove | ||
| // the __session cookie even though the user is still authenticated. | ||
| if (!token.getRawString()) { | ||
| return; | ||
| } | ||
| eventBus.emit(events.TokenUpdate, { token }); | ||
| if (token.jwt) { | ||
| @@ -509,9 +529,14 @@ export class Session extends BaseResource implements SessionResource { | ||
| }); | ||
| return tokenResolver.then(token => { | ||
| const rawString = token.getRawString(); | ||
| if (!rawString) { | ||
| // Throw so retry logic in getToken() can handle it, | ||
| // rather than silently returning null (which callers interpret as "signed out"). | ||
| throw new ClerkRuntimeError('Token fetch returned empty response', { code: 'network_error' }); | ||
| } | ||
| this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); | ||
| // Return null when raw string is empty to indicate signed-out state | ||
| return token.getRawString() || null; | ||
| return rawString; | ||
| }); | ||
| } | ||
| @@ -541,6 +566,12 @@ export class Session extends BaseResource implements SessionResource { | ||
| // This allows concurrent calls to continue using the stale token | ||
| tokenResolver | ||
| .then(token => { | ||
| // Never cache or dispatch empty tokens — preserve the stale-but-valid | ||
| // token in cache instead of replacing it with an empty one. | ||
| if (!token.getRawString()) { | ||
| return; | ||
| } | ||
| // Cache the resolved token for future calls | ||
| // Re-register onRefresh to handle the next refresh cycle when this token approaches expiration | ||
| SessionTokenCache.set({ | ||
Oops, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.