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:
parent
0ded02b34c
commit
463227c947
1 changed files with 586 additions and 347 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue