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:
parent
c0bcdc7e4a
commit
69fcdd364c
1 changed files with 447 additions and 214 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue