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
2 changed files with 447 additions and 214 deletions
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue