inspector/clients/cli/src/clear-stored-auth-for-relogin.ts
cliffhall 69fcdd364c 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>
2026-08-28 05:38:30 -04:00

131 lines
5.4 KiB
TypeScript

import {
NodeOAuthStorage,
resetNodeOAuthStorageCache,
} from "@inspector/core/auth/node/storage-node.js";
import {
DEFAULT_REVOCATION_TIMEOUT_MS,
executeOAuthRevocation,
planOAuthRevocation,
type OAuthRevocationPlan,
type TokenRevocationOutcome,
} from "@inspector/core/auth/revocation.js";
import { createProxyFetch } from "@inspector/core/mcp/node/proxyFetch.js";
/** Same canonicalisation as CLI `normalizeServerUrl` (avoid cycles). */
function normalizeServerUrl(serverUrl: string): string {
try {
return new URL(serverUrl).href;
} catch {
return serverUrl;
}
}
/**
* Delete stored OAuth state for an HTTP(S) server URL from the shared store
* (`--relogin`) so the next connect cannot silently reuse tokens. This removes
* the store entry (not a per-run ignore). No-op when `serverUrl` is missing.
*
* Clears **both** the raw URL and the `new URL().href`-normalised form. Runtime
* OAuth storage is keyed by the transport's raw `url` string, while some writers
* (and earlier clear paths) use the normalised key — mirroring
* `findStoredServerState` in `cli.ts`, which already tries both on read.
*/
export async function clearStoredAuthForRelogin(
serverUrl: string | undefined,
options?: { revoke?: boolean },
): Promise<TokenRevocationOutcome | undefined> {
if (!serverUrl?.trim()) return undefined;
const raw = serverUrl.trim();
const normalized = normalizeServerUrl(raw);
const storage = new NodeOAuthStorage();
// 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, 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
? []
: await Promise.all(
keys.map((key) => planOAuthRevocation({ serverUrl: key, storage })),
);
await storage.clear(raw);
if (normalized !== raw) {
await storage.clear(normalized);
}
// 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;
}
/**
* Send each plan's requests, in order, and report the outcome that matters most.
*
* 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 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 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 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 sendPlans(
plans: OAuthRevocationPlan[],
): Promise<TokenRevocationOutcome | undefined> {
const fetchFn = createProxyFetch() ?? fetch;
const deadlineAt = Date.now() + DEFAULT_REVOCATION_TIMEOUT_MS;
let reported: TokenRevocationOutcome | undefined;
let lastSkip: TokenRevocationOutcome | undefined;
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 "${plan.serverUrl}" was attempted`,
};
continue;
}
const outcome = await executeOAuthRevocation(plan, {
fetchFn,
timeoutMs: remainingMs,
});
if (outcome.status === "skipped" && outcome.reason === "no_tokens") {
lastSkip = outcome;
continue;
}
if (outcome.status === "failed") {
if (reported?.status !== "failed") reported = outcome;
} else {
reported ??= outcome;
}
}
return reported ?? lastSkip;
}