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
7 changed files with 2159 additions and 46 deletions

View file

@ -134,5 +134,22 @@ export {
// Discovery
export { discoverScopes } from "./discovery.js";
// RFC 7009 token revocation (#2144)
export {
DEFAULT_REVOCATION_TIMEOUT_MS,
buildRevocationRequest,
revocationAuthMethods,
revokeStoredOAuthTokens,
revokeToken,
selectRevocableToken,
} from "./revocation.js";
export type {
RevocationRequestParams,
RevokeStoredOAuthTokensParams,
RevokeTokenParams,
TokenRevocationOutcome,
TokenRevocationSkipReason,
} from "./revocation.js";
// Logging (re-exported from core/logging)
export { silentLogger } from "../logging/index.js";

335
core/auth/revocation.ts Normal file
View file

@ -0,0 +1,335 @@
/**
* RFC 7009 OAuth 2.0 Token Revocation (#2144).
*
* Clearing the Inspector's OAuth state used to delete the *local* copy of the
* tokens and stop there: the access token, and the refresh token when one was
* issued, stayed valid at the authorization server until they expired on their
* own. From the AS's side nothing happened — the client just went quiet — so a
* day of connect/disconnect iteration left it holding a pile of live grants for
* sessions that had ended hours ago. RFC 7009 §1 describes exactly this case
* (a client invalidating its tokens when the user logs out or walks away) and
* this module is the request that closes it.
*
* Everything here is **best-effort**, and deliberately so: the local clear is
* what the user asked for, and it must finish whether or not the AS cooperates.
* An authorization server that advertises no `revocation_endpoint` is left
* behaving exactly as before, a network error or a non-2xx is reported and
* swallowed, and a short timeout keeps a slow AS from hanging the teardown.
* Every path therefore returns a {@link TokenRevocationOutcome} rather than
* throwing — a caller is telling us to forget these tokens, and there is no
* failure here that should stop it.
*/
import type {
OAuthClientInformation,
OAuthMetadata,
OAuthTokens,
} from "@modelcontextprotocol/client";
import { selectClientAuthMethod } from "@modelcontextprotocol/client";
import type { InspectorLogger } from "../logging/index.js";
import type { OAuthStorage } from "./storage.js";
/**
* How long a revocation request may take before it is abandoned.
*
* Short on purpose. This runs inside a disconnect the user has already
* committed to, so an unreachable or wedged authorization server must not hold
* the teardown open — five seconds is long enough for a real endpoint on a slow
* link and short enough that a dead one is not felt as a hang.
*/
export const DEFAULT_REVOCATION_TIMEOUT_MS = 5000;
/** Why a revocation request was not sent. */
export type TokenRevocationSkipReason =
/** The caller turned revocation off for this server. */
| "disabled"
/** The authorization server advertises no `revocation_endpoint`. */
| "no_endpoint"
/** Nothing is stored for this server, so there is no grant to revoke. */
| "no_tokens"
/** Authorization-server metadata could not be resolved at all. */
| "no_metadata";
/**
* What happened on the revocation leg. Never an exception: see the module
* comment — the local clear runs regardless, so this is reported, not thrown.
*/
export type TokenRevocationOutcome =
| {
status: "revoked";
/** Which token RFC 7009 §2.1 was asked about. */
tokenTypeHint: "refresh_token" | "access_token";
endpoint: string;
}
| { status: "skipped"; reason: TokenRevocationSkipReason }
| { status: "failed"; detail: string; endpoint?: string };
/**
* The token to name in the request, and the hint that describes it.
*
* A refresh token is preferred when one exists, and not merely as a
* tie-breaker: RFC 7009 §2.1 says an AS asked to revoke a refresh token SHOULD
* also invalidate the access tokens issued under the same grant, so the single
* request covers both. Naming the access token instead would leave the refresh
* token — the long-lived half, and the one a user cannot wait out — alive.
*/
export function selectRevocableToken(
tokens: OAuthTokens | undefined,
): { token: string; tokenTypeHint: "refresh_token" | "access_token" } | null {
if (!tokens) return null;
if (tokens.refresh_token) {
return { token: tokens.refresh_token, tokenTypeHint: "refresh_token" };
}
if (tokens.access_token) {
return { token: tokens.access_token, tokenTypeHint: "access_token" };
}
return null;
}
/**
* The client-authentication methods to consider for the revocation endpoint.
*
* RFC 8414 gives `revocation_endpoint_auth_methods_supported` the same default
* as the token endpoint's when it is omitted, so fall through to
* `token_endpoint_auth_methods_supported` before giving up. An empty result
* lets {@link selectClientAuthMethod} apply the SDK's own default rather than
* this module inventing a second one.
*/
export function revocationAuthMethods(metadata: OAuthMetadata): string[] {
return (
metadata.revocation_endpoint_auth_methods_supported ??
metadata.token_endpoint_auth_methods_supported ??
[]
);
}
export interface RevocationRequestParams {
endpoint: string;
token: string;
tokenTypeHint: "refresh_token" | "access_token";
clientInformation?: OAuthClientInformation;
supportedAuthMethods: string[];
}
/**
* Build the RFC 7009 §2.1 request — an `application/x-www-form-urlencoded` POST
* carrying `token` and `token_type_hint`, plus client authentication.
*
* Split out from {@link revokeToken} because *what is sent* is the part worth
* asserting directly: a credential in the wrong place (basic vs. post) is a
* silent 401 at a real AS and indistinguishable from "the server declined" in
* an end-to-end test.
*/
export function buildRevocationRequest(params: RevocationRequestParams): {
url: string;
init: RequestInit;
} {
const body = new URLSearchParams({
token: params.token,
token_type_hint: params.tokenTypeHint,
});
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const client = params.clientInformation;
if (client) {
const method = selectClientAuthMethod(client, params.supportedAuthMethods);
if (method === "client_secret_basic" && client.client_secret) {
// Deliberately byte-identical to what the SDK's `applyBasicAuth` sends at
// the token endpoint: the raw `id:secret`, *not* the form-urlencoded pair
// RFC 6749 §2.3.1 asks for. Matching the RFC here instead would mean this
// request and the token request present the same credential differently,
// so an authorization server holding a secret with a reserved character
// could accept one and reject the other — a failure that would look like
// "revocation is broken" rather than like an encoding disagreement.
// Whatever the AS accepted to mint these tokens is what ends them.
headers.Authorization = `Basic ${base64Encode(`${client.client_id}:${client.client_secret}`)}`;
} else if (method === "client_secret_post" && client.client_secret) {
body.set("client_id", client.client_id);
body.set("client_secret", client.client_secret);
} else {
// Public client (or a confidential one with no secret on hand): RFC 7009
// §2.1 still wants the client identified.
body.set("client_id", client.client_id);
}
}
return {
url: params.endpoint,
init: { method: "POST", headers, body: body.toString() },
};
}
/**
* Base64 for the Basic credential, in both runtimes this code runs in.
*
* `btoa` is the browser's and is byte-oriented, so the percent-encoded
* credential above (which is ASCII by construction) is safe to pass it. Node
* has `Buffer`; reaching for it first would pull a Node built-in into the
* browser bundle, which the #1769 build gate rejects outright.
*/
function base64Encode(value: string): string {
if (typeof btoa === "function") return btoa(value);
/* v8 ignore next 2 -- Node 22 and every supported browser define btoa; this is the belt-and-braces branch. */
return Buffer.from(value, "utf8").toString("base64");
}
export interface RevokeTokenParams extends RevocationRequestParams {
fetchFn: typeof fetch;
timeoutMs?: number;
}
/**
* POST the revocation request and classify the answer.
*
* RFC 7009 §2.2 makes a 200 the success case *and* the response to a token the
* AS does not recognize, which is why an already-expired token is not an error
* here. Anything else — a 4xx, a 5xx, a network failure, the timeout — is
* reported as `failed` and goes no further than a warning at the call site.
*/
export async function revokeToken(
params: RevokeTokenParams,
): Promise<TokenRevocationOutcome> {
const { url, init } = buildRevocationRequest(params);
const timeoutMs = params.timeoutMs ?? DEFAULT_REVOCATION_TIMEOUT_MS;
try {
const response = await params.fetchFn(url, {
...init,
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
return {
status: "failed",
endpoint: url,
detail:
`revocation endpoint responded ${response.status} ${response.statusText}`.trim(),
};
}
return {
status: "revoked",
tokenTypeHint: params.tokenTypeHint,
endpoint: url,
};
} catch (err) {
return {
status: "failed",
endpoint: url,
detail: err instanceof Error ? err.message : String(err),
};
}
}
export interface RevokeStoredOAuthTokensParams {
serverUrl: string;
storage: OAuthStorage;
fetchFn: typeof fetch;
/**
* `false` skips the request entirely — the deliberate case from #2144, where
* a user wants to watch a server cope with a client that walks off still
* holding live tokens.
*/
enabled?: boolean;
timeoutMs?: number;
logger?: InspectorLogger;
}
/**
* Revoke the tokens the Inspector holds for `serverUrl`, reading everything it
* needs out of the OAuth store.
*
* Called immediately **before** the local clear, since the store is where the
* token, the client credentials, and the discovered `revocation_endpoint` all
* live — after the clear there is nothing left to revoke with.
*
* The metadata comes from the cache the OAuth flow already populated rather
* than from a fresh discovery round-trip: the tokens being revoked were minted
* by that same authorization server, so its cached document is the document
* that describes them, and re-discovering would add two network legs to a
* teardown for no new information. A server that has never completed an OAuth
* flow has no cached metadata *and* no tokens, so it short-circuits either way.
*/
export async function revokeStoredOAuthTokens(
params: RevokeStoredOAuthTokensParams,
): Promise<TokenRevocationOutcome> {
const { serverUrl, logger } = params;
if (params.enabled === false) {
return { status: "skipped", reason: "disabled" };
}
const outcome = await computeOutcome(params);
if (outcome.status === "failed") {
logger?.warn(
{ serverUrl, endpoint: outcome.endpoint, detail: outcome.detail },
"Token revocation failed; clearing local OAuth state anyway",
);
} else if (outcome.status === "skipped" && outcome.reason === "no_endpoint") {
logger?.debug(
{ serverUrl },
"Skipping token revocation: authorization server metadata has no revocation_endpoint",
);
}
return outcome;
}
/**
* The client credentials to authenticate the revocation request with.
*
* Mirrors `BaseOAuthClientProvider.clientInformation`: the preregistered
* (static, issuer-independent) entry wins, then the per-issuer dynamic
* registration. Reading only the second would silently drop client
* authentication for every server configured with an `oauth.clientId` — the
* confidential case, where an authorization server is most likely to *require*
* it and answer 401.
*/
async function resolveClientInformation(
storage: OAuthStorage,
serverUrl: string,
): Promise<OAuthClientInformation | undefined> {
return (
(await storage.getClientInformation(serverUrl, true)) ??
(await storage.getClientInformation(serverUrl, false))
);
}
async function computeOutcome(
params: RevokeStoredOAuthTokensParams,
): Promise<TokenRevocationOutcome> {
const { serverUrl, storage, fetchFn } = params;
// Read the token first. It is the cheapest disqualifier, and a server with no
// stored grant should not provoke a metadata read at all.
let tokens: OAuthTokens | undefined;
let metadata: OAuthMetadata | null;
let clientInformation: OAuthClientInformation | undefined;
try {
tokens = await storage.getTokens(serverUrl);
const revocable = selectRevocableToken(tokens);
if (!revocable) return { status: "skipped", reason: "no_tokens" };
metadata = await storage.getServerMetadata(serverUrl);
if (!metadata) return { status: "skipped", reason: "no_metadata" };
if (!metadata.revocation_endpoint) {
return { status: "skipped", reason: "no_endpoint" };
}
clientInformation = await resolveClientInformation(storage, serverUrl);
return await revokeToken({
endpoint: metadata.revocation_endpoint,
token: revocable.token,
tokenTypeHint: revocable.tokenTypeHint,
clientInformation,
supportedAuthMethods: revocationAuthMethods(metadata),
fetchFn,
timeoutMs: params.timeoutMs,
});
} catch (err) {
// A store that cannot be read (a corrupt blob, a remote backend that 500s)
// is not a reason to abandon the clear the user asked for.
return {
status: "failed",
detail: err instanceof Error ? err.message : String(err),
};
}
}

View file

@ -238,6 +238,7 @@ import {
type HandleAuthChallengeOptions,
} from "../auth/challenge.js";
import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js";
import type { TokenRevocationOutcome } from "../auth/revocation.js";
import type { OAuthTokens } from "@modelcontextprotocol/client";
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
import { createFetchTracker } from "./fetchTracking.js";
@ -813,6 +814,7 @@ export class InspectorClient extends InspectorClientEventTarget {
return Promise.resolve();
},
initialConfig: oauthConfig,
logger: this.logger,
enterpriseManagedAuth: options.enterpriseManagedAuth,
installEnterpriseManagedAuth: options.installEnterpriseManagedAuth,
dispatchOAuthComplete: (detail) =>
@ -6722,8 +6724,25 @@ export class InspectorClient extends InspectorClientEventTarget {
/**
* Clears OAuth tokens and client information
*/
async clearOAuthTokens(): Promise<void> {
await this.oauthManager?.clearOAuthTokens();
/**
* Drop this server's stored OAuth state, revoking the grant at the
* authorization server first (RFC 7009, #2144).
*
* The revocation is best-effort — an authorization server that advertises no
* `revocation_endpoint` is left behaving exactly as before, and a network
* error, a non-2xx or a timeout is reported in the returned outcome rather
* than thrown — so the local clear always completes. Pass
* `{ revoke: false }` to skip it.
*/
async clearOAuthTokens(options?: {
revoke?: boolean;
}): Promise<TokenRevocationOutcome> {
return (
(await this.oauthManager?.clearOAuthTokens(options)) ?? {
status: "skipped",
reason: "no_tokens",
}
);
}
/**

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> {

View file

@ -1392,6 +1392,7 @@ export function createRemoteApp(
tokenUrl?: string;
enterpriseManaged?: boolean;
requestRefreshToken?: boolean;
revokeOnClear?: boolean;
} => {
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
const o = v as Record<string, unknown>;
@ -1427,6 +1428,11 @@ export function createRemoteApp(
) {
return false;
}
// #2144 — shape only, same as the flag above: the read side keeps just an
// explicit `false`, so a stray `true` reads back as the default anyway.
if (o.revokeOnClear !== undefined && typeof o.revokeOnClear !== "boolean") {
return false;
}
return true;
};
// A roots array: each entry must have a string `uri` and, when present, a
@ -1540,7 +1546,7 @@ export function createRemoteApp(
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
logWarn(
{ route: "/api/servers", id, droppedKey: "oauth" },
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope?, requestRefreshToken? }`.",
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope?, requestRefreshToken?, revokeOnClear? }`.",
);
delete valObj.oauth;
}
@ -1852,6 +1858,15 @@ export function createRemoteApp(
error: "settings.oauthRequestRefreshToken must be a boolean",
};
}
if (
obj.oauthRevokeOnClear !== undefined &&
typeof obj.oauthRevokeOnClear !== "boolean"
) {
return {
ok: false,
error: "settings.oauthRevokeOnClear must be a boolean",
};
}
if (
obj.oauthOnInsufficientScope !== undefined &&
obj.oauthOnInsufficientScope !== "reauthorize" &&
@ -1984,6 +1999,10 @@ export function createRemoteApp(
if (obj.oauthRequestRefreshToken === false) {
value.oauthRequestRefreshToken = false;
}
// #2144: same shape — the default is on, so only the opt-out travels.
if (obj.oauthRevokeOnClear === false) {
value.oauthRevokeOnClear = false;
}
if (
obj.oauthOnInsufficientScope === "reauthorize" ||
obj.oauthOnInsufficientScope === "throw"

View file

@ -617,6 +617,11 @@ export function storedFieldsToInspectorSettings(
if (stored.oauth?.requestRefreshToken === false) {
settings.oauthRequestRefreshToken = false;
}
// Same inverted default as the flag above: revocation is on unless the entry
// explicitly opted out. (#2144)
if (stored.oauth?.revokeOnClear === false) {
settings.oauthRevokeOnClear = false;
}
// Mirror the stdio working directory for the form. Like the OAuth fields, an
// empty string coerces to absent so the form's "(inherit)" placeholder shows.
if (stored.cwd) settings.cwd = stored.cwd;
@ -750,6 +755,10 @@ export function inspectorSettingsToStoredFields(
if (settings.oauthRequestRefreshToken === false) {
oauthFields.requestRefreshToken = false;
}
// Same omit-the-default rule as the flag above. (#2144)
if (settings.oauthRevokeOnClear === false) {
oauthFields.revokeOnClear = false;
}
if (Object.keys(oauthFields).length > 0) {
out.oauth = oauthFields;
}

View file

@ -193,6 +193,14 @@ export type StoredMCPServer = MCPServerConfig & {
* Inspector-specific. (#2068)
*/
requestRefreshToken?: boolean;
/**
* Whether clearing this server's OAuth state also revokes the grant at the
* authorization server (RFC 7009). Defaults to `true`; only `false` is
* written to disk, so an entry that never touched the setting keeps a
* minimal diff. See {@link InspectorServerSettings.oauthRevokeOnClear}.
* Inspector-specific. (#2144)
*/
revokeOnClear?: boolean;
/**
* Custom query parameters appended to the OAuth **authorization request**
* (never the token request) — e.g. Keycloak's `kc_idp_hint`, OIDC's
@ -613,6 +621,12 @@ export interface OAuthSettings {
* reason as `authorizationParams`; `undefined` means the default, on.
*/
requestRefreshToken?: boolean;
/**
* Whether clearing the stored OAuth state revokes the grant at the
* authorization server (#2144). Optional for the same reason as
* `authorizationParams`; `undefined` means the default, on.
*/
revokeOnClear?: boolean;
}
/**
@ -818,6 +832,24 @@ export interface InspectorServerSettings {
* the stored OAuth state is cleared.
*/
oauthRequestRefreshToken?: boolean;
/**
* Whether clearing this server's stored OAuth state first revokes the grant
* at the authorization server, per RFC 7009 (#2144). `undefined` (the
* default) means on; persisted as `oauth.revokeOnClear` only when explicitly
* off.
*
* On is the right default because the alternative is silent: the Inspector
* deletes its local copy and the access token — and the refresh token, which
* is long-lived by design — stay valid at the authorization server until they
* expire on their own, leaving it holding grants for sessions that ended
* hours ago.
*
* Turning it off is a testing affordance rather than only an escape hatch: a
* client that walks away still holding live tokens is a case a server author
* may want to reproduce on purpose. It is read at clear time, not at connect
* time, so toggling it takes effect on the next clear without reconnecting.
*/
oauthRevokeOnClear?: boolean;
/**
* When true, connect via the configured enterprise IdP (EMA) instead of
* interactive OAuth to the MCP authorization server. Per-server OAuth