feat(auth): revoke OAuth tokens at the authorization server on clear (#2144)

Clearing the Inspector's OAuth state deleted the local copy of the tokens
and stopped there. The access token — and the refresh token, which is
long-lived by design — stayed valid at the authorization server until they
expired on their own, so a day of connect/disconnect iteration left it
holding grants for sessions that ended hours ago. RFC 7009 §1 describes
exactly that case, and nothing in the repo constructed a revocation
request, so a server author implementing `revocation_endpoint` had no way
to exercise it with the tool that exists to exercise this.

core/auth/revocation.ts builds and sends the request; the three clear paths
call it, so the behavior is written once — web's "Clear OAuth state and
disconnect" (both the active and stored-only paths), the TUI's Auth tab,
and the CLI's --relogin. It runs BEFORE the local clear, because the token,
the client credentials and the discovered `revocation_endpoint` all live in
the store the clear is about to empty. The request names the refresh token
when there is one: §2.1 asks the AS to also invalidate the access tokens
issued under the same grant, so one request covers both halves.

Best-effort by construction. Every path returns a TokenRevocationOutcome
rather than throwing: no advertised endpoint means nothing is sent and that
authorization server behaves exactly as before, and a network error, a
non-2xx, a 5s timeout or an unreadable store is reported and nothing more.
Forgetting the tokens is what the user asked for. `lost_authorization_state`
recovery deliberately skips it — it clears a half-finished flow to retry it,
and an authorization that never completed has no grant to revoke.

Client authentication resolves the preregistered slot first, mirroring
BaseOAuthClientProvider.clientInformation; reading only the dynamic slot
would send no authentication for a server configured with `oauth.clientId`.
The Basic credential is byte-identical to the SDK's applyBasicAuth so this
request and the token request cannot present the same secret differently.

Opt out per server with `oauth.revokeOnClear` (default on, only `false`
written to disk) or per CLI run with --no-revoke. Read at clear time rather
than connect time, so toggling needs no reconnect. Turning it off is a
testing affordance: a client that walks away still holding live tokens is a
case a server author may want to reproduce.

test-servers now advertises `revocation_endpoint` and serves POST
/oauth/revoke, faithful on §2.2 (an unknown token is a success) and §2.1
(revoking a refresh token invalidates the access tokens under the same
grant), with oauth-revocation-http.json / oauth-no-revocation-http.json as
showcase configs.

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 00:48:57 -04:00
commit 65fd2b9e35

View file

@ -13,6 +13,11 @@ import type { OAuthClientInformation } from "@modelcontextprotocol/client";
import { mcpAuth } from "../auth/mcpAuth.js";
import type { OAuthStorage } from "../auth/storage.js";
import { parseOAuthState } from "../auth/utils.js";
import {
revokeStoredOAuthTokens,
type TokenRevocationOutcome,
} from "../auth/revocation.js";
import type { InspectorLogger } from "../logging/index.js";
import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js";
import type { ClientConfig } from "../client/types.js";
import { EmaClientNotConfiguredError } from "../auth/ema/clientConfigError.js";
@ -71,6 +76,8 @@ export interface OAuthManagerParams {
dispatchOAuthComplete: (detail: { tokens: OAuthTokens }) => void;
dispatchOAuthAuthorizationRequired: (detail: { url: URL }) => void;
dispatchOAuthError: (detail: { error: Error }) => void;
/** Used for the best-effort RFC 7009 revocation warning (#2144). */
logger?: InspectorLogger;
}
/**
@ -488,16 +495,42 @@ export class OAuthManager {
}
}
async clearOAuthTokens(): Promise<void> {
/**
* 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).
*
* `options.revoke === false` skips the request. That is not only an escape
* hatch for an authorization server that mishandles it — disconnecting while
* still holding live tokens is a case a user may want to reproduce
* deliberately, to watch how a server under test copes with it.
*/
async clearOAuthTokens(options?: {
revoke?: boolean;
}): Promise<TokenRevocationOutcome> {
if (!this.oauthConfig?.storage) {
return;
return { status: "skipped", reason: "no_tokens" };
}
const serverUrl = this.getServerUrl();
const outcome = await revokeStoredOAuthTokens({
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;
}
async isOAuthAuthorized(): Promise<boolean> {