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

@ -4,8 +4,8 @@ import {
} from "@inspector/core/auth/node/storage-node.js";
import {
DEFAULT_REVOCATION_TIMEOUT_MS,
clearAndPlanRevocation,
executeOAuthRevocation,
planOAuthRevocation,
type OAuthRevocationPlan,
type TokenRevocationOutcome,
} from "@inspector/core/auth/revocation.js";
@ -52,22 +52,23 @@ export async function clearStoredAuthForRelogin(
// with, so the grant actually in use is the one whose outcome is reported.
// They are deliberately not deduplicated; see `sendPlans`.
const keys = normalized === raw ? [raw] : [normalized, raw];
const plans =
options?.revoke === false
? []
: await Promise.all(
keys.map((key) => planOAuthRevocation({ serverUrl: key, storage })),
);
await storage.clear(raw);
if (normalized !== raw) {
await storage.clear(normalized);
// Each key's state is taken and deleted in ONE atomic storage step, so the
// clear is never a separate check-then-act over an already-read snapshot.
const plans: OAuthRevocationPlan[] = [];
for (const key of keys) {
plans.push(
await clearAndPlanRevocation({
serverUrl: key,
storage,
enabled: options?.revoke !== false,
}),
);
}
// Drop the in-process singleton so the next connect cannot reuse a cleared
// entry from the NodeOAuthStorage cache.
resetNodeOAuthStorageCache();
return plans.length > 0 ? sendPlans(plans) : undefined;
return options?.revoke === false ? undefined : sendPlans(plans);
}
/**
@ -106,7 +107,12 @@ async function sendPlans(
let lastSkip: TokenRevocationOutcome | undefined;
for (const plan of plans) {
const remainingMs = deadlineAt - Date.now();
if (remainingMs <= 0) {
// A plan that already knows its answer needs no network, so the budget is
// irrelevant to it. Synthesising exhaustion here would warn that a grant
// may still be live when the key held no grant at all — a false alarm, and
// one that outranks the real outcome under the failure-first rule below.
const needsNetwork = plan.outcome === undefined;
if (needsNetwork && remainingMs <= 0) {
reported ??= {
status: "failed",
detail: `the ${DEFAULT_REVOCATION_TIMEOUT_MS}ms revocation budget was exhausted before "${plan.serverUrl}" was attempted`,
@ -115,7 +121,7 @@ async function sendPlans(
}
const outcome = await executeOAuthRevocation(plan, {
fetchFn,
timeoutMs: remainingMs,
timeoutMs: needsNetwork ? remainingMs : undefined,
});
if (outcome.status === "skipped" && outcome.reason === "no_tokens") {
lastSkip = outcome;