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

@ -4,7 +4,9 @@ import {
} from "@inspector/core/auth/node/storage-node.js";
import {
DEFAULT_REVOCATION_TIMEOUT_MS,
revokeStoredOAuthTokens,
executeOAuthRevocation,
planOAuthRevocation,
type OAuthRevocationPlan,
type TokenRevocationOutcome,
} from "@inspector/core/auth/revocation.js";
import { createProxyFetch } from "@inspector/core/mcp/node/proxyFetch.js";
@ -36,22 +38,27 @@ export async function clearStoredAuthForRelogin(
const raw = serverUrl.trim();
const normalized = normalizeServerUrl(raw);
const storage = new NodeOAuthStorage();
// RFC 7009 (#2144): revoke before the clear, since the token, the client
// credentials and the discovered `revocation_endpoint` all live in the store
// this is about to empty. Best-effort — the outcome is returned for the
// caller to report, never thrown, so `--relogin` succeeds regardless.
// RFC 7009 (#2144), ordered snapshot -> clear -> revoke. Everything the
// requests need is read first, because the clear empties the store; but the
// clear then runs immediately rather than behind the network. Waiting would
// hold this process's in-memory view of a *shared*, file-backed store for up
// to five seconds, and another CLI/TUI writing a fresh grant in that window
// would be erased by the clear that followed.
//
// Both spellings are cleared below, so both are revoked from — a stale entry
// under the other key is a live grant at the authorization server, and
// deleting it locally without revoking is exactly the leak this closes. The
// normalised key goes first because that is the precedence
// `findStoredServerState` reads with, so the grant actually in use is the one
// whose outcome is reported. They are deliberately not deduplicated; see
// `revokeStoredKeys`.
const revocation =
// Both spellings are cleared, so both are planned from — a stale entry under
// the other key is a live grant at the authorization server, and deleting it
// locally without revoking is exactly the leak this closes. The normalised
// key goes first because that is the precedence `findStoredServerState` reads
// 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
? undefined
: await revokeStoredKeys(storage, [normalized, raw]);
? []
: await Promise.all(
keys.map((key) => planOAuthRevocation({ serverUrl: key, storage })),
);
await storage.clear(raw);
if (normalized !== raw) {
await storage.clear(normalized);
@ -59,58 +66,54 @@ export async function clearStoredAuthForRelogin(
// Drop the in-process singleton so the next connect cannot reuse a cleared
// entry from the NodeOAuthStorage cache.
resetNodeOAuthStorageCache();
return revocation;
return plans.length > 0 ? sendPlans(plans) : undefined;
}
/**
* Revoke every grant held under `keys`, in order, and report the outcome that
* matters most.
* Send each plan's requests, in order, and report the outcome that matters most.
*
* Both keys are about to be deleted, so both are revoked from: a stale entry
* under the other spelling is still a live grant at the authorization server,
* and deleting it locally without revoking is the leak this whole change
* closes.
* Both key spellings are cleared, so both are planned from: a stale entry under
* the other spelling is still a live grant at the authorization server, and
* deleting it locally without revoking is the leak this whole change closes.
*
* There is deliberately **no** cross-key deduplication. It looked cheap — the
* two keys are usually two spellings of one server — but it can only be done
* by pre-reading a single token, and `revokeStoredOAuthTokens` enumerates every
* issuer slot under a key. So a shared *active* token would have skipped the
* second key entirely, taking any additional issuer-bound grant under it with
* the local delete. A duplicate RFC 7009 request is harmless (§2.2 makes an
* unknown token a success), which is a much better trade than a missed one.
* two keys are usually two spellings of one server — but it can only be done by
* pre-reading a single token, and a plan enumerates every issuer slot under a
* key. So a shared *active* token would have skipped the second key entirely,
* taking any additional issuer-bound grant under it with the local delete. A
* duplicate RFC 7009 request is harmless (§2.2 makes an unknown token a
* success), which is a much better trade than a missed one.
*
* One budget across both plans, for the same reason a plan shares one across
* its grants: two keys would otherwise double the wait.
*
* Reporting prefers a **failure** over any success — a grant still live at the
* authorization server is what the user needs to hear about, and a success
* would otherwise silence the warning. Failing that it is the first key's
* outcome that was not "nothing to do", which with `keys` in
* would otherwise silence the warning. Failing that it is the first plan's
* outcome that was not "nothing to do", which with the keys in
* `findStoredServerState` precedence means the grant the CLI would actually
* have connected with. When no key holds a token, the last "nothing to do"
* have connected with. When no key held a token, the last "nothing to do"
* answer is returned so the caller can still tell "no tokens" from "this
* authorization server advertises no revocation endpoint".
*/
async function revokeStoredKeys(
storage: NodeOAuthStorage,
keys: string[],
async function sendPlans(
plans: OAuthRevocationPlan[],
): Promise<TokenRevocationOutcome | undefined> {
const fetchFn = createProxyFetch() ?? fetch;
// One budget across both keys, for the same reason `revokeStoredOAuthTokens`
// shares one across grants: two keys would otherwise double the wait a user
// feels before `--relogin` gets on with the local delete.
const deadlineAt = Date.now() + DEFAULT_REVOCATION_TIMEOUT_MS;
let reported: TokenRevocationOutcome | undefined;
let lastSkip: TokenRevocationOutcome | undefined;
for (const key of new Set(keys)) {
for (const plan of plans) {
const remainingMs = deadlineAt - Date.now();
if (remainingMs <= 0) {
reported ??= {
status: "failed",
detail: `the ${DEFAULT_REVOCATION_TIMEOUT_MS}ms revocation budget was exhausted before "${key}" was attempted`,
detail: `the ${DEFAULT_REVOCATION_TIMEOUT_MS}ms revocation budget was exhausted before "${plan.serverUrl}" was attempted`,
};
continue;
}
const outcome = await revokeStoredOAuthTokens({
serverUrl: key,
storage,
const outcome = await executeOAuthRevocation(plan, {
fetchFn,
timeoutMs: remainingMs,
});