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
7 changed files with 447 additions and 214 deletions

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,
});

View file

@ -796,6 +796,11 @@ function App({
// A connect attempt supersedes whatever the last disconnect reported —
// including one still in flight, which is what the counter bump retires.
disconnectAttemptRef.current++;
// And whatever a pending clear was going to do (#2144). The server-name
// check alone 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
// would tear down the session this connect just established.
clearOAuthAttemptRef.current++;
setDisconnectError(null);
const finishConnect = async () => {

View file

@ -80,10 +80,11 @@ describe("clearServerOAuthState", () => {
expect(clearOAuthTokens).toHaveBeenCalledWith({ revoke: false });
});
// #2144 — the non-active path revokes from the store directly. The whole
// point is that it happens *before* the clear: after it there is no token,
// no client id and no cached metadata left to build a request from.
it("revokes before clearing when this server is not the active connection", async () => {
// #2144 — the non-active path revokes from the store directly. The snapshot
// is taken before the clear (after it there is no token, client id or cached
// metadata to build a request from), but the clear itself runs before the
// network — so by the time the request goes out the store is already empty.
it("clears before sending, using the snapshot it took first", async () => {
await storage.saveTokens(SERVER_URL, {
access_token: "tok",
token_type: "Bearer",
@ -97,7 +98,7 @@ describe("clearServerOAuthState", () => {
response_types_supported: ["code"],
});
let tokensAtRequestTime: unknown;
let tokensAtRequestTime: unknown = "unset";
const fetchFn = vi.fn<typeof fetch>(async () => {
tokensAtRequestTime = await storage.getTokens(SERVER_URL);
return new Response(null, { status: 200 });
@ -115,7 +116,9 @@ describe("clearServerOAuthState", () => {
tokenTypeHint: "refresh_token",
endpoint: "https://as.example.com/revoke",
});
expect(tokensAtRequestTime).toBeDefined();
// The store was already empty when the request went out — that is the
// ordering, and it is what stops a concurrently-written grant being wiped.
expect(tokensAtRequestTime).toBeUndefined();
expect(await storage.getTokens(SERVER_URL)).toBeUndefined();
});

View file

@ -1,5 +1,6 @@
import {
revokeStoredOAuthTokens,
executeOAuthRevocation,
planOAuthRevocation,
type TokenRevocationOutcome,
} from "@inspector/core/auth/revocation.js";
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
@ -43,7 +44,7 @@ export interface ClearServerOAuthStateResult {
/**
* Clear persisted OAuth state (tokens, DCR/CIMD client id, PKCE, etc.) for an
* HTTP MCP server, revoking the grant at the authorization server first. When
* HTTP MCP server, revoking the grant at the authorization server. When
* clearing the active connection, uses the live client so in-memory flow state
* is reset too.
*
@ -71,14 +72,20 @@ export async function clearServerOAuthState(
// No proxied fetch on hand means no request we could usefully make, so the
// leg is reported as skipped rather than attempted against the page origin.
const fetchFn = params.fetchFn;
const revocation: TokenRevocationOutcome =
revoke && fetchFn
? await revokeStoredOAuthTokens({
serverUrl,
storage: params.oauthStorage,
fetchFn,
})
: { status: "skipped", reason: "disabled" };
// Snapshot → clear → revoke. The clear must not wait on the network: this
// server can be inactive when the call starts and complete a *fresh*
// authorization while the request is in flight, at which point an unconditional
// clear afterwards would delete the new credentials. The session checks in
// `useOAuthRecovery` run after this helper returns and cannot protect the
// store, so the ordering is what does (#2144).
const plan = await planOAuthRevocation({
serverUrl,
storage: params.oauthStorage,
enabled: revoke && fetchFn !== undefined,
});
await params.oauthStorage.clear(serverUrl);
const revocation: TokenRevocationOutcome = fetchFn
? await executeOAuthRevocation(plan, { fetchFn })
: { status: "skipped", reason: "disabled" };
return { cleared: true, revocation };
}

View file

@ -6,11 +6,13 @@ import {
aggregateOutcomes,
buildRevocationRequest,
revocationAuthMethods,
revokeStoredOAuthTokens,
executeOAuthRevocation,
planOAuthRevocation,
revokeToken,
selectRevocableToken,
} from "@inspector/core/auth/revocation.js";
import type { InspectorLogger } from "@inspector/core/logging/index.js";
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
/** A fully-typed `InspectorLogger` double, so the mock's shape is checked. */
function fakeLogger(): InspectorLogger {
@ -339,7 +341,32 @@ describe("revokeToken", () => {
});
});
describe("revokeStoredOAuthTokens", () => {
/**
* Compose the two halves the way every caller does, minus the `storage.clear`
* between them — these tests are about what is planned and sent, and the
* ordering guarantee itself is asserted separately below.
*/
async function revokeStoredOAuthTokens(params: {
serverUrl: string;
storage: BrowserOAuthStorage;
fetchFn: typeof fetch;
enabled?: boolean;
timeoutMs?: number;
logger?: InspectorLogger;
}): Promise<TokenRevocationOutcome> {
const plan = await planOAuthRevocation({
serverUrl: params.serverUrl,
storage: params.storage,
enabled: params.enabled,
});
return executeOAuthRevocation(plan, {
fetchFn: params.fetchFn,
timeoutMs: params.timeoutMs,
logger: params.logger,
});
}
describe("revokeStoredOAuthTokens (plan + execute)", () => {
let storage: BrowserOAuthStorage;
beforeEach(async () => {
@ -890,3 +917,87 @@ describe("revokeStoredOAuthTokens", () => {
);
});
});
// The reason the API is two halves rather than one call (#2144, review round
// 13). Revoking first and clearing afterwards delays the clear by however long
// the network takes, and a FRESH authorization completing in that window is
// then deleted by a clear reasoning about the grant it replaced.
describe("plan / clear / execute ordering", () => {
let storage: BrowserOAuthStorage;
beforeEach(async () => {
storage = new BrowserOAuthStorage();
await storage.clear(SERVER_URL);
await storage.saveServerMetadata(
SERVER_URL,
metadata({ issuer: "https://as.example.com" }),
);
await storage.saveTokens(
SERVER_URL,
{ access_token: "old", token_type: "Bearer", refresh_token: "old-r" },
{ issuer: "https://as.example.com" },
);
});
it("does not delete a grant written while the request is in flight", async () => {
let releaseRequest: () => void = () => {};
const inFlight = new Promise<void>((resolve) => {
releaseRequest = resolve;
});
const fetchFn = vi.fn<typeof fetch>(async () => {
await inFlight;
return new Response(null, { status: 200 });
});
// The caller's real sequence: snapshot, clear, then send.
const plan = await planOAuthRevocation({ serverUrl: SERVER_URL, storage });
await storage.clear(SERVER_URL);
const sending = executeOAuthRevocation(plan, { fetchFn });
// A fresh authorization lands while the request is still out.
await storage.saveTokens(
SERVER_URL,
{ access_token: "new", token_type: "Bearer", refresh_token: "new-r" },
{ issuer: "https://as.example.com" },
);
releaseRequest();
await sending;
// The new grant survived, and the OLD token is what was revoked.
expect((await storage.getTokens(SERVER_URL))?.access_token).toBe("new");
expect(
new URLSearchParams(String(fetchFn.mock.calls[0]![1]!.body)).get("token"),
).toBe("old-r");
});
it("sends the snapshot even though the store was emptied first", async () => {
const fetchFn = vi.fn<typeof fetch>(
async () => new Response(null, { status: 200 }),
);
const plan = await planOAuthRevocation({ serverUrl: SERVER_URL, storage });
await storage.clear(SERVER_URL);
await expect(
executeOAuthRevocation(plan, { fetchFn }),
).resolves.toMatchObject({ status: "revoked" });
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it("plans nothing when revocation is disabled", async () => {
const plan = await planOAuthRevocation({
serverUrl: SERVER_URL,
storage,
enabled: false,
});
const fetchFn = vi.fn<typeof fetch>();
expect(plan.grants).toHaveLength(0);
await expect(executeOAuthRevocation(plan, { fetchFn })).resolves.toEqual({
status: "skipped",
reason: "disabled",
});
expect(fetchFn).not.toHaveBeenCalled();
});
});

View file

@ -199,10 +199,12 @@ describe("OAuthManager", () => {
expect(manager.getOAuthFlowStep()).toBeUndefined();
});
// #2144 — the ordering is the contract, not an implementation detail: the
// revocation request is built from the token, the client id and the cached
// metadata that `clear` is about to delete.
it("revokes at the authorization server before clearing local state", async () => {
// #2144 — the ordering is the contract, not an implementation detail. The
// request is built from the token, the client id and the cached metadata
// `clear` deletes, so the snapshot has to be taken first; but the clear
// must then run BEFORE the network, or a fresh authorization completing
// during it would be deleted by a clear reasoning about the old grant.
it("clears local state before waiting on the revocation request", async () => {
const params = createMockParams();
const storage = params.initialConfig.storage!;
vi.mocked(storage.getTokens).mockResolvedValue({
@ -234,7 +236,7 @@ describe("OAuthManager", () => {
status: "revoked",
tokenTypeHint: "refresh_token",
});
expect(order).toEqual(["revoke", "clear"]);
expect(order).toEqual(["clear", "revoke"]);
});
it("clears local state even when the revocation request fails", async () => {

View file

@ -18,7 +18,11 @@ import {
waitForOAuthWellKnown,
} from "@modelcontextprotocol/inspector-test-server";
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
import { revokeStoredOAuthTokens } from "@inspector/core/auth/revocation.js";
import {
executeOAuthRevocation,
planOAuthRevocation,
} from "@inspector/core/auth/revocation.js";
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
import type { OAuthMetadata } from "@modelcontextprotocol/client";
const CLIENT_ID = "test-2144-revocation";
@ -192,6 +196,18 @@ describe("OAuth token revocation (RFC 7009)", () => {
expect(await tokenAccepted(tokens.access_token)).toBe(true);
});
/**
* The caller's real sequence — snapshot, clear, send — so this exercises the
* ordering the product uses rather than a convenience wrapper.
*/
async function clearAndRevoke(
storage: BrowserOAuthStorage,
): Promise<TokenRevocationOutcome> {
const plan = await planOAuthRevocation({ serverUrl, storage });
await storage.clear(serverUrl);
return executeOAuthRevocation(plan, { fetchFn: fetch });
}
it("advertises a revocation endpoint", () => {
expect(metadata.revocation_endpoint).toBe(`${serverUrl}/oauth/revoke`);
});
@ -202,11 +218,7 @@ describe("OAuth token revocation (RFC 7009)", () => {
const tokens = await authorize();
expect(await tokenAccepted(tokens.access_token)).toBe(true);
const outcome = await revokeStoredOAuthTokens({
serverUrl,
storage: await seededStorage(tokens),
fetchFn: fetch,
});
const outcome = await clearAndRevoke(await seededStorage(tokens));
expect(outcome).toMatchObject({
status: "revoked",
@ -219,11 +231,9 @@ describe("OAuth token revocation (RFC 7009)", () => {
it("revokes an access token when no refresh token was issued", async () => {
const tokens = await authorize();
const outcome = await revokeStoredOAuthTokens({
serverUrl,
storage: await seededStorage({ access_token: tokens.access_token }),
fetchFn: fetch,
});
const outcome = await clearAndRevoke(
await seededStorage({ access_token: tokens.access_token }),
);
expect(outcome).toMatchObject({
status: "revoked",
@ -236,8 +246,8 @@ describe("OAuth token revocation (RFC 7009)", () => {
// server has already expired must not be reported as a failure.
it("treats an already-unknown token as revoked", async () => {
const storage = await seededStorage({ access_token: "never-issued" });
await expect(
revokeStoredOAuthTokens({ serverUrl, storage, fetchFn: fetch }),
).resolves.toMatchObject({ status: "revoked" });
await expect(clearAndRevoke(storage)).resolves.toMatchObject({
status: "revoked",
});
});
});