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
|
|
@ -14,7 +14,8 @@ import { mcpAuth } from "../auth/mcpAuth.js";
|
|||
import type { OAuthStorage } from "../auth/storage.js";
|
||||
import { parseOAuthState } from "../auth/utils.js";
|
||||
import {
|
||||
revokeStoredOAuthTokens,
|
||||
executeOAuthRevocation,
|
||||
planOAuthRevocation,
|
||||
type TokenRevocationOutcome,
|
||||
} from "../auth/revocation.js";
|
||||
import type { InspectorLogger } from "../logging/index.js";
|
||||
|
|
@ -499,12 +500,15 @@ export class OAuthManager {
|
|||
* Revoke the grant at the authorization server (RFC 7009), then drop the
|
||||
* local OAuth state.
|
||||
*
|
||||
* Revocation runs **first** and reads what it needs out of the same store the
|
||||
* next line wipes — the token, the client credentials and the discovered
|
||||
* `revocation_endpoint` all live there, so after the clear there is nothing
|
||||
* left to revoke with. It is best-effort by construction: every failure is
|
||||
* reported through the returned outcome and the clear proceeds regardless,
|
||||
* because forgetting the tokens is what the caller actually asked for (#2144).
|
||||
* The order is **snapshot → clear → revoke**, and it matters. Everything the
|
||||
* requests need is read out of the store first, because the clear empties it;
|
||||
* but the clear then runs immediately rather than behind the network, because
|
||||
* a fresh authorization completing during a five-second revocation would
|
||||
* otherwise be deleted by a clear reasoning about the grant it replaced.
|
||||
*
|
||||
* Best-effort by construction: every failure is reported through the returned
|
||||
* outcome and the clear has already happened regardless, because forgetting
|
||||
* the tokens is what the caller actually asked for (#2144).
|
||||
*
|
||||
* `options.revoke === false` skips the request. That is not only an escape
|
||||
* hatch for an authorization server that mishandles it — disconnecting while
|
||||
|
|
@ -519,18 +523,19 @@ export class OAuthManager {
|
|||
}
|
||||
|
||||
const serverUrl = this.getServerUrl();
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
const plan = await planOAuthRevocation({
|
||||
serverUrl,
|
||||
storage: this.oauthConfig.storage,
|
||||
fetchFn: this.params.effectiveAuthFetch,
|
||||
enabled: options?.revoke,
|
||||
logger: this.params.logger,
|
||||
});
|
||||
await this.oauthConfig.storage.clear(serverUrl);
|
||||
|
||||
this.oauthFlowState = null;
|
||||
this.pendingAuthorizationScope = undefined;
|
||||
return outcome;
|
||||
return executeOAuthRevocation(plan, {
|
||||
fetchFn: this.params.effectiveAuthFetch,
|
||||
logger: this.params.logger,
|
||||
});
|
||||
}
|
||||
|
||||
async isOAuthAuthorized(): Promise<boolean> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue