Files
7d339c186f fix(auth): send the user to login when the session expires (MUL-7028) (#7993)
* fix(auth): send the user to login when the session expires

A 401 mid-session only dropped the stored token: the auth store kept
`user` set and `status: "authenticated"`, so neither shell's login gate
(desktop `App.tsx`, web `[workspaceSlug]/layout.tsx`) ever fired. The
shell stayed mounted and, with the token now gone, every following
request went out unauthenticated — the user got a wall of "missing
authorization" toasts and no way forward.

Add `sessionExpired()` to the auth store: an idempotent teardown that
lands in exactly the state a cold boot with a dead token reaches. The
api client's 401 hook drives it, and AuthInitializer's boot rejection
now delegates to it, so both dead-session paths converge on one
implementation. Mobile has done this since its own 401 handler.

The login page says why the user is there, rather than reading as the
app having dropped their work for no reason. The claim is only made when
this client actually presented a credential the server rejected — a
first visit's identity probe 401s too.

Two follow-ons the new path exposed:

- Drop the query cache on an involuntary session end. Every query has
  `staleTime: Infinity`, so whoever signed in next would render the dead
  session's data and never refetch it away. Explicit logout already did
  this; the involuntary path did not, and it could not be reached before.
- Snapshot the CLI-authorize token before probing the cookie. The probe
  is expected to 401 for a token-mode session, and the teardown clears
  the very key the fallback then looks for, so token-mode users could
  never authorize a CLI.

Co-authored-by: multica-agent <[email protected]>

* fix(desktop): keep the daemon running when the UI session expires

Desktop's `onLogout` stops the local daemon, and routing session expiry
through it meant an expiring UI credential killed a process that was
authenticating with its own PAT and might be running agent work.

Split the two events. `onSessionExpired` defaults to `onLogout`, so a
shell only needs its own handler when part of its logout teardown is too
destructive for an expiry it did not ask for — which on desktop is the
daemon, and the tab layout with it. An account switch stays safe because
it is handled at the other end: `daemon:sync-token` mints a fresh PAT
and restarts the daemon whenever the user id changes.

The no-token boot exit runs the same teardown rather than the logout
one. Otherwise the daemon survived the expiry and then died on the next
app start, when a missing token was read as the user having signed out.

Both desktop teardowns move to platform/session-teardown with their side
effects injected, so the difference between them is testable without an
Electron window — verified failing when expiry stops the daemon.

`AuthInitializer` no longer takes `onLogout`: every unauthenticated exit
now delegates to the store, which owns both callbacks. `initCore` takes
a named options object, since onLogin / onLogout / onSessionExpired are
three adjacent `() => void` that only argument order told apart.

Co-authored-by: multica-agent <[email protected]>

* fix(auth): erase the previous session's client state on expiry

Review found two holes in the new expiry path.

The expiry only dropped the query cache, while logout also resets
in-memory drafts, clears per-workspace persisted state, the tab layout
and the last-workspace cookie. But the screen an expiry lands on offers
a login form, and the next person to use it is not guaranteed to be the
one who was just signed in — and when they share a workspace, the
post-login stale-tab validator finds nothing to prune. A colleague on a
shared machine would inherit drafts, chat selection and tab titles: the
exact leak drafts/cleanup-registry exists to close.

So an expiry now erases exactly what a logout erases. The teardown moves
to core's platform/session-cleanup, shared by both, and desktop's expiry
path resets tabs too — leaving the daemon as the only difference between
the two, which is the point of the split. Unsent drafts are lost on an
expiry now, the same as on logout; keeping them would mean gating the
restore on user identity, which the login page cannot decide before it
has one.

Second, the idempotence guard sat above the credential teardown, so a
login attempt that 401s — Desktop's deep link writes a token, then
verifies it — never left "unauthenticated" and returned with the invalid
token still in storage, to be replayed at the next launch. That was a
regression against the old 401 handler, which always removed it.
Dropping the credential now happens first and unconditionally; the guard
only suppresses the parts that end a session.

Both regressions verified failing before the fix.

Co-authored-by: multica-agent <[email protected]>

* fix(auth): clean up the previous session on a cold-start rejection

The cleanup only ran on a transition out of `authenticated`, and it
enumerated the keys to remove from the workspace list in the Query cache.
The case that leaks worst satisfies neither: the app starts, the stale
token on disk is rejected at the identity probe, and no workspace list
was ever loaded — so A's drafts and chat state sat on disk waiting for
whoever signed in next.

Key it on being unauthenticated instead of on leaving `authenticated`,
and enumerate the stored keys rather than the workspace list. Storage
adapters gain an optional `keys()`; workspace-scoped entries are
`${base}:${slug}` and every base is known, so the slugs no longer have
to be. That also catches workspaces the last session had left.

This cannot sign anyone out. Reaching `unauthenticated` IS the decision
to end a session, and only a 401 on a presented credential or having no
credential at all gets there — a client that cannot reach the server
settles on `recovering` and keeps its token and its drafts. Pinned with
regression tests for offline, 500 and 504.

Also closes the test gap that hid this: the "no workspace list" case
asserted only the global tabs key, so it passed while every
per-workspace key survived.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-04 14:59:03 +08:00
..