fix(auth): clear before waiting on the network, not after (#2144)

Copilot review round 13. Three of its four findings were one root cause, and
it is a design flaw in the premise this PR started from.

Revoking *before* the clear meant the clear waited up to five seconds on the
network — and anything that wrote OAuth state in that window was then deleted
by a clear reasoning about the grant it replaced. A same-server reauthorization
in the TUI (whose Disconnect/Connect accelerators stay live), a stored-only web
clear for a server that becomes active mid-flight, and another CLI/TUI process
writing to the shared file-backed store all hit it.

`revokeStoredOAuthTokens` is therefore split into `planOAuthRevocation` and
`executeOAuthRevocation`, and every caller runs plan → `storage.clear()` →
execute. The snapshot still precedes the clear, because the store is where the
token, the credentials and the endpoint live; but the clear no longer waits on
anything. There is deliberately no single-call wrapper left, so no caller can
reintroduce the racy order by accident.

Fourth finding, same file: `handleConnect` now retires `clearOAuthAttemptRef`
alongside `disconnectAttemptRef`. The server-name check does not cover
disconnect/reconnect to the SAME server — the name still matches on the other
side of it — so a clear still in flight could tear down the session the connect
had just established.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-28 05:38:30 -04:00
commit 69fcdd364c

View file

@ -102,11 +102,19 @@ v2/main/
│ │ # answered, since that is the base its
│ │ # token request is made against — #2110;
│ │ # revocation.ts RFC 7009 token revocation —
│ │ # the request the three clear paths send
│ │ # BEFORE wiping local state, since the
│ │ # token, the client credentials and the
│ │ # discovered `revocation_endpoint` all live
│ │ # in the store the clear empties. Names the
│ │ # the request the three clear paths send.
│ │ # TWO halves — planOAuthRevocation then
│ │ # executeOAuthRevocation — and the order
│ │ # between them is the contract: plan →
│ │ # storage.clear() → execute. The snapshot
│ │ # must precede the clear (the token, the
│ │ # client credentials and the discovered
│ │ # `revocation_endpoint` all live in the store
│ │ # it empties); the CLEAR must precede the
│ │ # network, or a fresh authorization
│ │ # completing during a 5s request is deleted
│ │ # by a clear reasoning about the grant it
│ │ # replaced. Names the
│ │ # REFRESH token when there is one (§2.1 asks
│ │ # the AS to invalidate the access tokens
│ │ # under the same grant, so one request