Skip to content

fix(auth): sign out every tab when one tab signs out - #1936

Merged
dawsontoth merged 3 commits into
stagefrom
claude/cross-tab-sign-out
Oct 13, 2026
Merged

dawsontoth merged 3 commits into
stagefrom
claude/cross-tab-sign-out

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Tab B is open on a cluster page and tab A signs out. Before tab B is reloaded, it shows a "ClientError — Not allowed" toast. Dawson hit this while hand-testing the release candidate on stage, and RUM recorded it: tab A's POST /Logout/ at 13:21:46.4, then tab B's GET /Cluster/<id> 403 at 47.5.

Tab B never learns of the sign-out. Studio had no cross-tab sign-out signal, so the tab keeps polling on the ended session. Central manager answers a request with no session 403, not 401 (checkUser → notAllowed(), HarperFast/central-manager#938). The 401 interceptor ignores that, and errorHandler (src/react-query/queryClient.ts:94-124) toasts it.

This is not a regression from today's merges. Before them, no cross-tab sign-out listener existed.

Solution

When a tab's session ends, every other tab signed in as that user signs out too.

❓ Your call:

  • Announce only after /Logout/ succeeds. A failed /Logout/ must not sign out other tabs while the session is still live.
  • Use a dedicated key, not the marker record. loadUser also drops the marker when a check merely times out.
  • Followers write no localStorage (forgetAppSignInEndedElsewhere). Their copy of the markers is stale.

Dawson's decision: a password change signs out every tab. The design is recorded in DESIGN.md.

A 403 while signed in still toasts, and a CM 401 still goes to sign-in quietly. Both are pinned by tests.

Not covered:

  • a frozen or back-forward-cached tab that misses the event;
  • a session that ends with no tab noticing (until central-manager#938);
  • instance and cluster sign-outs.

Verification

  • Tests fail on base with the exact toast: toast.error('ClientError', { description: 'Not allowed', id: 'request-forbidden' }). I mutation-checked every guard.
  • Gates all exit 0 (Node 24.21.0): full vitest, tsc -b, oxlint, dprint check.
  • Browser: not verified. The steps for Dawson are below.
Root cause in detail
time (13:21) tab event
:46.413 A POST /Logout/
:46.431 A lands on /sign-in
:47.509 B GET /Cluster/<id> returns 403
:47.546 B console.error, a side effect of the toast
:48.5 B reloaded onto /sign-in

The same shape happens again at 14:36 UTC. Tab B's request came about 4s after its page load, not on the 10s poll, so it was most likely the refocus refetch (staleTime is 1.9s).

Central manager side:

  • src/lib/access.js:13-17: checkUser throws notAllowed() when there is no user.
  • src/resources/cluster/Cluster.js: get and post call checkUser first.
  • src/resources/Login.js:165+: Logout.post clears the session's user.

The 403 body is {code:"ClientError", title:"Not allowed"}, and describeError maps it to the toast's title and description.

Studio's only earlier storage listeners were the API explorer's credential cleanup (git log -S"'storage'").

Decisions, in full
  • Announcing after /Logout/. The first version also announced from the sign-out sweep, before /Logout/. Review showed two problems. A failed /Logout/ would sign out tabs whose session is still live. And a tab sent to sign-in that early could be signed back in, only for the pending /Logout/ to end the new session. What remains is a window of about half a round trip. A tab-B request that central manager handles between /Logout/ and tab A's logoutOnSuccess can still toast. The stage trace (+1.1s) is well outside it.
  • Identity. A late 401 from user U's session, or U's sign-out reaching a tab after it signed in as V, must not end V. The sweep clears the user before /Logout/, so it remembers them for the announcement. The announcement is written from a finally.
  • The key. Following the marker would let one tab's central-manager blip sign out every tab. The explorer epoch's '*' slot belongs to the explorer.
  • Followers write no localStorage. Their stale markers could undo the other tab's next sign-in. Credentials only a follower knew about can outlive a failed /Logout/, as they already could from the sweep.
  • A storage key, not a BroadcastChannel. This matches the repo's other cross-tab signal. Switching would be cheap.
Tests and mutation checks

crossTabSignOut.test.tsx mounts the real AppRouted, routes, store and query client, and wires the installers as main.tsx does. A fake adapter plays central manager. The "other tab" is a second module graph sharing localStorage, and its writes are replayed as storage events. Cases:

  • A sign-out elsewhere: no toast, no request on the ended session, lands on sign-in.
  • /Logout/ fails: stays signed in.
  • Another tab's 401 teardown: follows.
  • This tab's own check is out during the sign-out: asks again, then lands on sign-in.
  • A 401 ending another user's session: stays signed in.
  • The sign-out arrives after this tab signed in as someone else: stays signed in.
  • A cluster-only sign-in or sign-out: stays signed in.
  • A failed check elsewhere: stays signed in.
  • A 403 while signed in still toasts. A CM 401 still routes quietly.

appSignOutElsewhere.test.ts covers:

  • the listener's filter (own user only, signed-out tabs, malformed values);
  • the re-check and its gates;
  • the named user, including after the sweep;
  • the announcement surviving a throwing teardown;
  • that the follower writes no storage, and drops tokens and in-flight checks.

signOutHandlers.test.ts covers the handler: it leaves localStorage alone and still clears when a step throws.

Mutations. I reverted each guard and confirmed a test goes red:

mutation fails
listener not installed 2 mounted cases, with the toast
no announcement 2 mounted cases and 1 unit test
the sweep announces before /Logout/ the failed-/Logout/ case and 1 unit test
follow any user 2 other-user mounted cases and the filter unit test
follow the marker record the failed-check mounted case
follow while signed out 1 unit test
follow during a check by marker 1 unit test
no re-check the in-flight mounted case and 1 unit test
remember while not checking 1 unit test
forget the swept user 1 mounted case and 1 unit test
announce outside finally 1 unit test
no generation bump, keep tokens, keep markers 1 unit test each
no cache clear 2 mounted cases
handler writes storage 2 handler tests
no sessionStorage clear 1 handler test
Gates

Node 24.21.0, with real exit codes:

  • On the rebased head c25e21b81, vitest run --maxWorkers=4 exited 0: 527 files, 5,126 passed, 1 expected fail, 11 skipped. tsc -b, oxlint --format stylish . and dprint check exited 0.
  • The husky hook ran vitest, oxlint, staged dprint and commitlint on every commit. Every commit passed it; none used --no-verify.
  • Under load average 30–150 from other sessions, three unrelated files timed out: editorApi, JobsTab and NewApplication. Each passed alone, and the hook passed on retry.
Browser steps for Dawson
  1. Open two stage tabs on a cluster page. In tab B's console, run addEventListener('storage', (e) => console.log('storage', e.key, e.newValue)).
  2. Sign out in tab A.
  3. With this PR: tab B logs storage Studio:AppSignOut {"user":"<id>",…} after tab A's /Logout/ completes, and goes to sign-in without being focused. There is no toast and no GET /Cluster/<id> 403.
  4. On current stage: on refocus, or within 10s, tab B's GET /Cluster/<id> returns 403 and the toast appears.
Cross-model review

Reviewers were codex gpt-5.6-sol (graded), gemini, a Cursor lens and Harper domain adjudication. Every round was independent.

  • Round 1 (e8b3db654). One major: the announcement went out before /Logout/. I moved it.

  • Round 2 (423d89006). Minor: the announcement carried no identity. I named the ended user and added a test for each trigger. I also moved the install flag after registration and trimmed comments.

  • Closing full round (9b9cdc738). Converged at minor.

    • Fixed: a tab mid-check ignored the announcement. It now re-checks.
    • Fixed: a throwing teardown could skip the announcement. It now runs in finally.
    • Taken: removed the narrating test comments.
    • Declined, as earlier-round nits:
      • Duplicated in-memory teardown: signOutAllLocally interleaves memory clears with storage writes.
      • Missing import extensions: the repo's imports are extensionless.
      • The installer discards its unsubscribe: production reloads fully. The mounted test now removes its listeners.
  • Final round on the rebased head c25e21b81 is in progress. The footer carries the closing round forward, and I will update it when that round finishes.

🤖 Generated with Claude Code

Related PRs: none found

Review-Coverage: authored=claude; ran=gemini,codex,cursor-muse,cursor-composer; adjudicated=domain; blocked=cursor-grok(failed); declined=cursor-kimi,muse; rounds=3; full=2 @ c25e21b

Review-Attention: deep ~30m (sensitive: clearAuthStateLocally.ts, authStore.ts; decisions: do-less-alternative, cross-tab-signal-vs-403-reprobe, storage-key-vs-broadcastchannel; raised: degraded review) @ c25e21b

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements cross-tab sign-out functionality, ensuring that signing out of the application in one tab automatically signs out all other open tabs. This is managed via a custom localStorage event (Studio:AppSignOut) and a new handler clearAuthStateAfterSignOutElsewhere that clears local tab state without overwriting shared storage. Comprehensive unit and integration tests have been added to validate this behavior. No review comments were provided, so there is no additional feedback.

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 77.59% 13429 / 17307
🔵 Statements 77.54% 14253 / 18381
🔵 Functions 72.43% 3529 / 4872
🔵 Branches 72.54% 10537 / 14525
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/auth/handlers/clearAuthStateLocally.ts 100% 100% 100% 100%
src/features/auth/store/authStore.ts 91.24% 81.95% 91.13% 91.71% 117, 203-214, 219-222, 270, 291, 312-323, 331, 336-339, 441-444, 804, 809, 883, 907
src/lib/installCrossTabSignOut.ts 80% 50% 100% 80% 8
Generated in workflow #2335 for commit c25e21b by the Vitest Coverage Report Action

@dawsontoth
dawsontoth marked this pull request as ready for review October 13, 2026 16:03
@dawsontoth
dawsontoth requested a review from a team as a code owner October 13, 2026 16:03
dawsontoth and others added 3 commits October 13, 2026 13:00
A tab left open on a cluster page while another tab signed out kept
polling central manager on the ended session. Central manager answers a
request with no session 403 "Not allowed" (checkUser -> notAllowed(),
HarperFast/central-manager#938), which the 401 interceptor ignores and
the global query error handler toasts as "ClientError: Not allowed".
Nothing told the open tab about the sign-out: Studio's only cross-tab
listeners were the API explorer's credential cleanup.

signOutAllLocally, the local clear that runs once the shared session has
ended (after /Logout/ succeeds, on a 401, after a password change), now
rewrites `Studio:AppSignOut`. Every other tab holding the app sign-in
follows that storage event with the same clear minus the shared
localStorage, so its route guards send it to sign-in before its next
poll. The sign-out sweep does not announce: it runs before /Logout/,
which can fail, and a tab sent to sign-in that early could sign in again
only for that /Logout/ to end the new session.

The follower keys off a dedicated key rather than the marker record,
because a tab also drops the app sign-in from that record when its
check merely fails (a timeout or a 5xx on /User/current). And it writes
no localStorage: its copy of the markers is a snapshot from its own
page load, and writing it back could undo the other tab's next sign-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `Studio:AppSignOut` announcement carried no identity, so a tab
followed any of them while it held an app sign-in. A 401 that ended
user U's session in one tab, or U's sign-out reaching a tab after it
had signed in as V, signed out a tab holding a live V session.

The announcement now names the user whose session ended, and a tab
follows only when that is its own app user. The sign-out sweep clears
the user before `/Logout/`, so it remembers them for the announcement
`signOutAllLocally` makes afterwards. A tab whose sign-in check has not
answered yet leaves it to that check.

Also sets the installer's flag only once the listener is registered,
and drops comments that restated DESIGN.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tab whose app sign-in check was in flight when another tab announced
the end of its user's session ignored the announcement, because it had
no user to compare yet. If central manager had answered that check just
before the `/Logout/`, the tab then signed in on the ended session and
its next poll toasted the 403 this branch removes. The tab now
remembers the announced user, and if its check comes back as them, asks
central manager again.

signOutAllLocally now announces from a `finally`, so a throwing step in
its teardown, such as an explorer listener, cannot cost the other tabs
the announcement after the ended user's identity was already taken.

The mounted test removes each test's `storage` listeners afterwards, so
none acts on a later test's events, and drops comments that only
narrated the next step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the claude/cross-tab-sign-out branch from 423d890 to c25e21b Compare October 13, 2026 17:02
@dawsontoth
dawsontoth merged commit 2ed4e7b into stage Oct 13, 2026
3 checks passed
@dawsontoth
dawsontoth deleted the claude/cross-tab-sign-out branch October 13, 2026 17:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant