inspector/core/auth/revocation.ts
cliffhall 6c499f3ea1 fix(auth): read enumerated issuers exactly, and stop the TUI tone going stale (#2144)
Copilot review round 4.

- `getTokens(url, issuer)` falls back to the legacy unkeyed slot when that
  issuer holds none — right for a connect, wrong for `collectGrants`, which
  labelled the fallback with the issuer it happened to be iterating and paired
  it with that issuer's credentials. During a partially migrated flow the clear
  could have sent an old, unbound token to a newly discovered authorization
  server. `OAuthStorage.getIssuerTokens` is the exact, no-fallback read; only
  the ctx-less read still falls back, which is the one place it belongs.
- The TUI's warning tone was sticky. It lived in its own state while ~30 call
  sites set ordinary OAuth notes without resetting it, so one revocation
  failure left every later "Authorization updated" yellow, across server
  switches. It is now DERIVED — `oauthMessageToneFor(message, warningText)` —
  so it cannot outlive the message that earned it, with nothing to remember to
  clear. Both setters stay plain `useState` setters: a `useCallback` wrapper
  would be a value `react-hooks/exhaustive-deps` demands in seven dependency
  arrays for an identity that never changes.
- Hook-level tests for the web wiring (previously missed): the per-server
  opt-out and the backend-proxied fetch are now asserted to reach
  `clearServerOAuthState`, so handing it the page-origin fetch — which a real
  authorization server's missing CORS headers would reject — fails a test.

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>
2026-08-28 02:58:04 -04:00

481 lines
19 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 the revocation endpoint advertises.
*
* RFC 8414 §2 gives `revocation_endpoint_auth_methods_supported` a default of
* **`client_secret_basic`** when it is omitted — it does *not* inherit
* `token_endpoint_auth_methods_supported`. Falling through to the token
* endpoint's list would make metadata advertising only `client_secret_post`
* there send POST credentials to a revocation endpoint that never advertised
* that method, which a strict server rejects.
*
* An empty result is returned rather than a literal `["client_secret_basic"]`
* because that is what makes {@link selectClientAuthMethod} apply exactly the
* RFC's default — `client_secret_basic` when the client holds a secret, `none`
* when it does not — while still honoring a `token_endpoint_auth_method` the
* client's own registration declares.
*/
export function revocationAuthMethods(metadata: OAuthMetadata): string[] {
return metadata.revocation_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 {
// The signal alone is not enough to bound this. In the browser the fetch is
// `createRemoteFetch`, which re-issues the call as a POST to `/api/fetch`
// and does not forward `init.signal`; the backend's outbound fetch gets no
// signal either. So a wedged authorization server would hold the teardown
// open indefinitely on exactly the path the timeout exists for. The race is
// what actually enforces the deadline; the signal is kept because it does
// cancel the direct-fetch paths (CLI, TUI, backend) rather than merely
// abandoning them.
const response = await withDeadline(
params.fetchFn(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }),
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),
};
}
}
/**
* Reject with a timeout error if `promise` has not settled within `timeoutMs`.
*
* The underlying request is abandoned rather than cancelled — nothing here can
* cancel a fetch the proxy already stripped the signal from. That is the right
* trade for this caller: the point is that the *teardown* proceeds, and the
* response, if it ever arrives, is a revocation we no longer need to wait for.
* The timer is cleared on the settled path so a caller is never held awake by it.
*/
async function withDeadline<T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() =>
reject(new Error(`revocation request timed out after ${timeoutMs}ms`)),
timeoutMs,
);
});
try {
return await Promise.race([promise, deadline]);
} finally {
/* v8 ignore next -- the Promise executor runs synchronously, so `timer` is
always assigned by the time this runs; the guard exists only because
TypeScript cannot see that. */
if (timer !== undefined) clearTimeout(timer);
}
}
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
* tokens, the client credentials, and the discovered `revocation_endpoint` all
* live — after the clear there is nothing left to revoke with. Every grant the
* clear will delete is covered, not just the active issuer's: see
* {@link collectGrants}.
*
* 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 registration bound to
* `issuer`. 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,
issuer?: string,
): Promise<OAuthClientInformation | undefined> {
return (
(await storage.getClientInformation(serverUrl, true)) ??
(await storage.getClientInformation(serverUrl, false, issuer))
);
}
/** One revocable grant held for a server, and which AS minted it. */
interface StoredGrant {
/** Undefined for the legacy unkeyed slot, which predates issuer binding. */
issuer?: string;
token: string;
tokenTypeHint: "refresh_token" | "access_token";
clientInformation?: OAuthClientInformation;
}
/**
* Every grant `clear(serverUrl)` is about to delete, deduplicated by token.
*
* `clear` drops **every** `byIssuer` slot, so reading only the context-free
* (active-issuer) token would leave an earlier authorization server's grant
* live while destroying the local record of it — the exact leak this feature
* exists to close, just moved one level down. A server that authorized against
* issuers A and B has two grants here, not one.
*
* The ctx-less read is included last and deduped: on an issuer-bound entry it
* returns the active issuer's token, which the loop above has already seen; on
* a legacy entry it is the only thing that returns anything at all. It is also
* the only read here allowed to fall back — an *enumerated* issuer is read
* exactly, so a legacy token is never mislabelled as belonging to it.
*/
async function collectGrants(
storage: OAuthStorage,
serverUrl: string,
): Promise<StoredGrant[]> {
const grants: StoredGrant[] = [];
const seen = new Set<string>();
const add = async (issuer?: string): Promise<void> => {
// Exact read for an enumerated issuer. `getTokens(serverUrl, issuer)` falls
// back to the legacy unkeyed slot when that issuer holds none, and treating
// the fallback as the issuer's would label an old, unbound token with a
// newly discovered authorization server and send it there.
const tokens =
issuer === undefined
? await storage.getTokens(serverUrl)
: await storage.getIssuerTokens(serverUrl, issuer);
const revocable = selectRevocableToken(tokens);
if (!revocable || seen.has(revocable.token)) return;
seen.add(revocable.token);
grants.push({
issuer,
...revocable,
clientInformation: await resolveClientInformation(
storage,
serverUrl,
issuer,
),
});
};
for (const issuer of await storage.listIssuers(serverUrl)) {
await add(issuer);
}
await add();
return grants;
}
/**
* Combine per-grant outcomes into the one this function reports.
*
* A **failure outranks a success**: a grant still live at the authorization
* server is what the caller needs to surface, and reporting another grant's
* success would leave that silent. A success outranks a skip for the same
* reason in the other direction — "revoked" is the more specific truth.
*
* Exported for its own test. `computeOutcome` returns early when there are no
* grants, so the empty case cannot arise from there — which is exactly why the
* fallback needs testing somewhere: nothing else would ever exercise it, and a
* function that returns `undefined` while typed otherwise is a trap for the
* next caller.
*/
export function aggregateOutcomes(
outcomes: TokenRevocationOutcome[],
): TokenRevocationOutcome {
return (
outcomes.find((o) => o.status === "failed") ??
outcomes.find((o) => o.status === "revoked") ??
outcomes[0] ?? { status: "skipped", reason: "no_tokens" }
);
}
async function computeOutcome(
params: RevokeStoredOAuthTokensParams,
): Promise<TokenRevocationOutcome> {
const { serverUrl, storage, fetchFn } = params;
try {
// Grants first. They are the cheapest disqualifier, and a server with no
// stored grant should not provoke a metadata read at all.
const grants = await collectGrants(storage, serverUrl);
if (grants.length === 0) return { status: "skipped", reason: "no_tokens" };
const metadata = await storage.getServerMetadata(serverUrl);
if (!metadata) return { status: "skipped", reason: "no_metadata" };
const endpoint = metadata.revocation_endpoint;
if (!endpoint) return { status: "skipped", reason: "no_endpoint" };
const supportedAuthMethods = revocationAuthMethods(metadata);
const outcomes: TokenRevocationOutcome[] = [];
for (const grant of grants) {
// Metadata is cached once per server, not per issuer, so it describes
// whichever authorization server was discovered last. Sending another
// issuer's token to *this* endpoint would hand a credential to a server
// that never minted it — worse than not revoking. So say plainly that the
// grant is being dropped unrevoked rather than doing either silently.
if (
grant.issuer !== undefined &&
metadata.issuer !== undefined &&
grant.issuer !== metadata.issuer
) {
outcomes.push({
status: "failed",
detail: `the cached authorization-server metadata is for ${metadata.issuer}, so the grant bound to ${grant.issuer} was cleared without revocation`,
});
continue;
}
outcomes.push(
await revokeToken({
endpoint,
token: grant.token,
tokenTypeHint: grant.tokenTypeHint,
clientInformation: grant.clientInformation,
supportedAuthMethods,
fetchFn,
timeoutMs: params.timeoutMs,
}),
);
}
return aggregateOutcomes(outcomes);
} 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),
};
}
}