fix(auth): make snapshot-and-clear one atomic storage step (#2144)

Copilot review round 18. Three of its findings were the same check-then-act:
`planOAuthRevocation` is asynchronous, so the gap between its reads and the
following `storage.clear()` is still a window — shorter than the network one
closed in round 13, but the same shape. An OAuth completion landing at any of
those awaits saved a grant the clear then destroyed.

`OAuthStorage.takeRevocationSnapshot(serverUrl)` reads what revocation needs
and clears the server in ONE synchronous pass over the in-memory state, with
the persist after the mutation. `clearAndPlanRevocation` replaces
`planOAuthRevocation`, and there is no separate `clear` call left in any
caller — the clear cannot be forgotten or reordered.

The snapshot returns values unparsed, deliberately: schema validation is pure
and belongs after the mutation, since running it inside would reintroduce the
`await` this exists to remove.

Two consequences worth naming. A failure of the take-and-clear now REJECTS
rather than returning an outcome, because the state was not cleared and the
caller's contract is that it was; both clients already have a rejection path.
And cross-process atomicity is explicitly not claimed — nothing in this store
takes a lock, every mutation is a read-modify-write over a loaded snapshot, and
`clear` alone always had that property.

Also from round 18:

- The CLI's budget check ran before considering `plan.outcome`, so a key that
  needed no network was reported as budget-exhausted — warning that a grant may
  be live when that key held none.
- A disconnect failure after a successful clear was reported as "could not
  clear OAuth state" in both clients. It now goes to the disconnect channel,
  and the success is still reported.

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 07:40:52 -04:00
commit 463227c947

View file

@ -14,8 +14,8 @@ import { mcpAuth } from "../auth/mcpAuth.js";
import type { OAuthStorage } from "../auth/storage.js";
import { parseOAuthState } from "../auth/utils.js";
import {
clearAndPlanRevocation,
executeOAuthRevocation,
planOAuthRevocation,
type TokenRevocationOutcome,
} from "../auth/revocation.js";
import type { InspectorLogger } from "../logging/index.js";
@ -500,11 +500,11 @@ export class OAuthManager {
* Drop this server's local OAuth state, and revoke the grant at the
* authorization server (RFC 7009).
*
* The order is **snapshot → clear → revoke**, and it matters. Everything the
* requests need is read out of the store first, because the clear empties it;
* but the clear then runs immediately rather than behind the network, because
* a fresh authorization completing during a five-second revocation would
* otherwise be deleted by a clear reasoning about the grant it replaced.
* The state is taken and deleted in one atomic step, and the requests are
* sent afterwards from what was taken. Both halves matter: the requests need
* state the clear destroys, and the clear must not wait on the network, or a
* fresh authorization completing during a five-second revocation would be
* deleted by a clear reasoning about the grant it replaced.
*
* Best-effort by construction: every failure is reported through the returned
* outcome and the clear has already happened regardless, because forgetting
@ -523,12 +523,14 @@ export class OAuthManager {
}
const serverUrl = this.getServerUrl();
const plan = await planOAuthRevocation({
// Takes the state and deletes it in ONE atomic storage step; separate
// reads followed by a separate clear are a check-then-act, and an OAuth
// completion landing between them would be destroyed by the clear.
const plan = await clearAndPlanRevocation({
serverUrl,
storage: this.oauthConfig.storage,
enabled: options?.revoke,
});
await this.oauthConfig.storage.clear(serverUrl);
this.oauthFlowState = null;
this.pendingAuthorizationScope = undefined;