Repository navigation
fix(auth): drop a stale API explorer credential when a tab reloads after missing a sign-out - #1956
Merged
Merged
Conversation
…ut's storage wipe A full local sign-out bumps the API explorer's sign-out generation (`Studio:ExplorerAuthEpoch`) and then wipes localStorage, which deleted that bump. The generation read 0 again, so a tab that missed the live `storage` event (reloading, frozen, in the back-forward cache) kept, on its next load, any explorer credential stamped 0: every credential made since the previous sign-out, which had reset it too. - `clearLocalStorage` now removes every key except the ones it is told to keep, attempting each removal and then throwing the first failure. `clearAuthStateLocally` keeps the generation key. Nothing writes it after the bump, so no rewrite can fail or overwrite another tab's. - The global bump collapses the record to one `'*'` slot above every entity's generation, so the kept record carries no instance or cluster ids into the next account's session. - The global bump runs in the same `finally` as the sign-out announcement, so a teardown step that throws no longer skips it. When a full store refuses it, the store notes that and the sign-out writes it again once its wipe has made room. - An explorer subscriber that throws no longer stops the sign-out's remaining bumps: each is caught and reported. Closes #1946 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rage members `Object.keys(localStorage)` leaves out a stored key whose name is also a `Storage` member (`length`, `key`, `getItem`, ...), so a wipe built on it would keep such a key. `localStorage.key(index)` lists every key, which is why `clearLocalStorage` uses it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||
…y a refused announcement - A full sign-out bumped every signed-in entity's explorer generation and then the global slot, so it wrote the record N+1 times and called each entity's subscriber twice. The per-entity bumps now wait while `signOutAllLocally` runs; its one collapsed write, still in the `finally`, lifts every entity above its old generation on its own. - A store too full to take the `Studio:AppSignOut` announcement left every other tab signed in until its next request came back 403. The refused announcement is now written by the same post-wipe step that rewrites a refused generation (`retrySignOutWritesAfterWipe`, renamed from `retryUnwrittenExplorerSignOut`), then removed, since the other tabs read the user from the event and the wipe keeps no trace of it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…after a refused write - The post-wipe retry forgot the pending announcement before writing it, so a retry storage also refused dropped it. It is now cleared only once the write lands. - A refused explorer-generation write still called every same-tab subscriber, each of which re-read storage to find nothing had changed. Subscribers now hear only about a write that landed. - `explorerBumpsDeferred` is renamed `fullSignOutInProgress`: the per-entity bumps it covers are skipped, not replayed. DESIGN.md says so, and that the guarantee needs storage to accept one write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one step The retry only worked if `clearAuthStateLocally` ran it after the wipe, and nothing held the two steps in that order. `wipeLocalStorageForSignOut` now does both: it keeps the explorer generation, and its `finally` runs the retry even when a removal failed. The generation key and the retry go back to being private. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d its announcement only once - When storage refused both the full sign-out's collapse and its retry, the next entity sign-out bumped that entity alone, so every other entity kept the generation the revoked credentials were stamped with. It now writes the owed collapse instead. - A refused announcement was kept pending after the post-wipe retry, with no later caller. It is now dropped there on purpose: sent any later, it could end a session the user has signed back into in another tab. DESIGN.md says both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t owes it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kriszyp
approved these changes
Oct 14, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
⊙ Problem
A sign-out bumps the API explorer's sign-out generation, then wipes
localStorage, which deleted the bump. A tab that missed the sign-out (reloading or frozen) checks its stored explorer credential against that generation when it loads. After the wipe it read 0 again, so a credential stamped 0 stayed usable (#1946).🤔 Should the sign-out's wipe keep anything at all?
Yes: the load-time check is the only one a tab that missed the sign-out gets, and every sign-out reset it. What is kept is a single count, with no user or instance in it.
💡 Solution
⚖️ Alternatives
signOutAllLocally— the bump then starts from nothing, so every sign-out writes the same count and a credential stamped after one sign-out survives the next.🤔 Keep the key through the wipe, or write it back after
localStorage.clear()?Keeping it means the normal path never writes the key after the bump, so another tab's bump in between survives. Writing back a snapshot would overwrite that bump. A full store needs the retry either way.
🔧 Changes
clearAuthStateLocally.tshands the wipe to the store, and its doc comment says the generation is the one thing kept.authStore.ts:wipeLocalStorageForSignOutkeeps the generation key, then in afinallywrites again what a full store refused: the generation, and the announcement, which it removes again once sent. A refused announcement gets this one more try only: sent later, it could end a session the user has signed back into in another tab.finally: one'*'count above every instance's count. If storage refuses that and its retry, this tab's next entity bump writes it instead.console.error.clearLocalStorageis imported here, and one helper reads a count, forgetExplorerAuthEpochtoo.signOutEntitiesLocallyjust aftersignOutLocallyin this file. No lines overlap; an entity sign-out there would also write an owed collapse.clearLocalStorage.tsremoves every key but the ones it is told to keep, one at a time. It tries each removal even when one fails, then throws the first failure.DESIGN.mdrecords the explorer rule and its one-write limit, the announcement retry, and fixes a stale line that put explorer settings inlocalStorage.🤔 Should a store that refuses every write still reach the other tabs?
Not here: both signals are storage writes, so other tabs then learn from their next request, as before. A BroadcastChannel fallback would reach running tabs only. Both open deferrals are this limit.
✅ Verification
stage: insignOutHandlers.test.ts, a credential stored before the 1st or 2nd sign-out is dropped when a tab that missed it reloads, through bothclearAuthStateLocallyandlogoutOnSuccess. Onstage(3e9e160) it is kept, withauthGeneration: 0. One write per sign-out (4 onstage) and the announcement after a full store's wipe (never sent onstage) fail there too.a8596fdd9): one tab holding a credential stamped 0 sat on a static page while the other ranclearAuthStateLocally, then reloaded the app. Onstagethe credential survived; on the branch it came back as{"ins-a":{"method":"bearer"}}.vitest run --maxWorkers=40 on the second run (553 files, 5,509 passed); the first run's one error, a Radix timer in the unrelatedAddOrganizationRoleModal.test.tsx, passes alone.tsc -b0,oxlint0,dprint check0. The husky hook's full run passed on every commit.The other new tests, and mutation checks
signOutHandlers.test.tsalso keeps a credential stored after the sign-out, fills the store so the bump lands only after the wipe, fails one removal during the wipe, and announces once when there is room. Its wipe test now expects only the generation key left, and a tab reload is fresh modules over the sharedlocalStorage.explorerAuthInvalidation.test.tspins the collapse, an entity bump after a full sign-out that threw, the owed collapse, no callbacks after a refused write, a throwing subscriber and a throwing teardown step.clearLocalStorage.test.tspins the kept keys, keys named likeStoragemembers, whichObject.keys(localStorage)leaves out, and that one failed removal does not stop the rest.appSignOutElsewhere.test.tsnow makes the teardown throw fromsignOutLocally, since a subscriber's error no longer escapes, and checks a twice-refused announcement is not sent later.I reverted each guard locally and confirmed a test goes red (17 mutations, all red):
localStorage.clear()againObject.keysclearLocalStoragetestsThe collapse finds the highest count with a loop rather than spreading every slot into
Math.max, which throwsRangeErrorpast about 150,000 slots on Node 24's main thread. No test pins that: a vitest worker accepted 450,000 arguments, and a record that large reaches jsdom's 5 MB storage quota.🤖 Generated with Claude Code
Related PRs: #1948 overlaps (merged; this moves the explorer bump into its sign-out
finallyand changes its teardown test), #1954 overlaps (open; addssignOutEntitiesLocallytoauthStore.ts, no shared lines)Complexity: complicated
Deferred findings
22601929e2fc387enit security —src/features/auth/store/authStore.ts— Both epoch writes refused: kept record retains entity ids and pre-sign-out credentials stay current — filed Sign-out: reach other tabs when storage refuses the sign-out's writes even after the wipe #19606f2a8235a818a050nit reliability —src/features/auth/store/authStore.ts— A twice-refused app sign-out announcement is never sent — filed Sign-out: reach other tabs when storage refuses the sign-out's writes even after the wipe #1960Review-Coverage: authored=claude; ran=gemini,codex,cursor-composer; adjudicated=domain; blocked=cursor-muse(no-receipt),cursor-grok(no-receipt); declined=cursor-kimi,muse; rounds=10; full=4 @ 0282f21
Review-Attention: deep ~30m (critical: clearLocalStorage.ts; sensitive: clearAuthStateLocally.ts, authStore.ts; decisions: wipe-owned-by-authstore, owed-collapse-overinvalidates, per-key-removal, do-less-alternative) @ 0282f21