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:
parent
baff393675
commit
65fd2b9e35
1 changed files with 2159 additions and 46 deletions
335
core/auth/revocation.ts
Normal file
335
core/auth/revocation.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue