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>
335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
/**
|
|
* 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),
|
|
};
|
|
}
|
|
}
|