Merge remote-tracking branch 'origin/v2/main' into v2/fix/2172-oidc-discovery-compat

Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	README.md
#	core/mcp/inspectorClient.ts
#	test-servers/src/test-server-oauth.ts
This commit is contained in:
cliffhall 2026-08-28 10:10:57 -04:00
commit 2eeab05d00
4 changed files with 8524 additions and 1598 deletions

View file

@ -139,5 +139,26 @@ export {
// Discovery
export { discoverScopes } from "./discovery.js";
// RFC 7009 token revocation (#2144)
export {
DEFAULT_REVOCATION_TIMEOUT_MS,
aggregateOutcomes,
buildRevocationRequest,
revocationAuthMethods,
clearAndPlanRevocation,
executeOAuthRevocation,
revokeToken,
selectRevocableToken,
} from "./revocation.js";
export type {
ExecuteOAuthRevocationParams,
OAuthRevocationPlan,
PlanOAuthRevocationParams,
RevocationRequestParams,
RevokeTokenParams,
TokenRevocationOutcome,
TokenRevocationSkipReason,
} from "./revocation.js";
// Logging (re-exported from core/logging)
export { silentLogger } from "../logging/index.js";

View file

@ -8,7 +8,7 @@ import {
OAuthClientInformationSchema,
OAuthTokensSchema,
} from "@modelcontextprotocol/core";
import type { OAuthStorage } from "./storage.js";
import type { OAuthStorage, RevocationSnapshot } from "./storage.js";
import {
type OAuthMemoryStore,
type ServerOAuthState,
@ -424,6 +424,29 @@ export class OAuthStorageBase implements OAuthStorage {
await this.persist();
}
async takeRevocationSnapshot(serverUrl: string): Promise<RevocationSnapshot> {
await this.ensureLoaded();
// Everything below is synchronous on purpose: read and clear against one
// view of the in-memory state, so nothing can land between them. The
// persist is the only await, and it happens after the mutation.
const state = this.memory.getState().getServerState(serverUrl);
const snapshot: RevocationSnapshot = {
byIssuer: Object.fromEntries(
Object.entries(state.byIssuer ?? {}).map(([issuer, slot]) => [
issuer,
{ tokens: slot?.tokens, clientInformation: slot?.clientInformation },
]),
),
legacyTokens: state.tokens,
legacyClientInformation: state.clientInformation,
preregisteredClientInformation: state.preregisteredClientInformation,
serverMetadata: state.serverMetadata,
};
this.memory.getState().clearServerState(serverUrl);
await this.persist();
return snapshot;
}
async clear(serverUrl: string): Promise<void> {
await this.ensureLoaded();
this.memory.getState().clearServerState(serverUrl);

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

@ -0,0 +1,743 @@
/**
* 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,
OAuthTokens,
} from "@modelcontextprotocol/client";
import { selectClientAuthMethod } from "@modelcontextprotocol/client";
import {
OAuthClientInformationSchema,
OAuthTokensSchema,
} from "@modelcontextprotocol/core";
import type { InspectorLogger } from "../logging/index.js";
import type { OAuthStorage, RevocationSnapshot } 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.
*
* It is a budget for the **whole** call, not per request: a server with several
* issuer-bound grants revokes them sequentially against one shared deadline, so
* the bound the user feels is this number regardless of how many grants the
* clear is about to delete.
*/
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.
*
* The default is returned **literally** rather than as an empty list. An empty
* list does not mean "apply the RFC default" to `selectClientAuthMethod`: it
* means "the metadata said nothing", and the SDK then honors whatever
* `token_endpoint_auth_method` the client's own registration declares. A DCR
* client registered `client_secret_post` would therefore put its credentials
* in the body of a request to an endpoint that promised only Basic, and the
* revocation would fail. Naming the default is what actually enforces it.
*/
export function revocationAuthMethods(metadata: CachedMetadata): string[] {
return (
metadata.revocation_endpoint_auth_methods_supported ?? [
"client_secret_basic",
]
);
}
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) {
// RFC 6749 §2.3.1: each half is form-urlencoded *before* the colon joins
// them. This deliberately differs from the SDK's `applyBasicAuth`, which
// base64s the raw pair — matching that was the first instinct, on the
// theory that a credential should be presented the same way here as at
// the token endpoint. It does not hold up: a client authenticated with
// `client_secret_post` never exercised the SDK's Basic path at all, so
// there is no precedent to match, and the raw form is ambiguous for a
// client id containing `:` and makes `btoa` throw outright on a
// non-Latin-1 secret. Encoding is what the server decodes.
const credentials = `${encodeURIComponent(client.client_id)}:${encodeURIComponent(client.client_secret)}`;
headers.Authorization = `Basic ${base64Encode(credentials)}`;
} 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 timeoutMs = params.timeoutMs ?? DEFAULT_REVOCATION_TIMEOUT_MS;
try {
// Inside the try: `encodeURIComponent` throws on a lone UTF-16 surrogate,
// which is valid JSON and so can reach here from a persisted client id or
// secret. Built outside, that would reject instead of returning a `failed`
// outcome — and every caller has already cleared its local state by now, so
// a rejection would break the documented best-effort guarantee.
const { url, init } = buildRevocationRequest(params);
// 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 {
// `params.endpoint`, not the built `url`: construction is inside the try
// now, so `url` may not exist on this path.
status: "failed",
endpoint: params.endpoint,
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 PlanOAuthRevocationParams {
serverUrl: string;
storage: OAuthStorage;
/**
* `false` plans nothing — 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;
}
export interface ExecuteOAuthRevocationParams {
fetchFn: typeof fetch;
timeoutMs?: number;
logger?: InspectorLogger;
}
/**
* Everything the revocation requests need, read out of the store **before** the
* local clear empties it.
*
* Split from the sending on purpose. `clearAndPlanRevocation` takes the state
* and deletes it in one atomic storage step; the requests then go out from this
* copy, which nothing can invalidate. Revoking first and clearing afterwards
* would delay the clear by however long the network takes, and a *fresh*
* authorization completing in that window would be deleted by a clear
* reasoning about the grant it replaced.
*/
export interface OAuthRevocationPlan {
serverUrl: string;
grants: StoredGrant[];
/** Slots that could not be read; reported beside whatever the rest do. */
failures: TokenRevocationOutcome[];
endpoint?: string;
supportedAuthMethods: string[];
metadataIssuer?: string;
/** Set when there is nothing to send, and why. */
outcome?: TokenRevocationOutcome;
}
/** A plan that sends nothing, carrying the reason. */
function emptyPlan(
serverUrl: string,
outcome: TokenRevocationOutcome,
failures: TokenRevocationOutcome[] = [],
): OAuthRevocationPlan {
return {
serverUrl,
grants: [],
failures,
supportedAuthMethods: [],
outcome:
failures.length > 0 ? aggregateOutcomes([...failures, outcome]) : outcome,
};
}
/**
* Take the server's OAuth state — deleting it — and build the revocation
* requests from what was taken.
*
* The take and the delete are one atomic storage step
* ({@link OAuthStorage.takeRevocationSnapshot}), which is what closes the
* check-then-act window: separate reads followed by a separate clear each yield
* at an `await`, and an OAuth completion landing in one of those gaps would
* save a fresh grant that the clear then destroyed.
*
* The state is cleared **even when revocation is disabled or nothing can be
* revoked** — clearing is what the caller asked for; the requests are the
* optional part.
*
* Every grant 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 one that describes them, and re-discovering would add two
* network legs to a teardown for no new information.
*/
export async function clearAndPlanRevocation(
params: PlanOAuthRevocationParams,
): Promise<OAuthRevocationPlan> {
const { serverUrl, storage } = params;
let snapshot: RevocationSnapshot;
try {
snapshot = await storage.takeRevocationSnapshot(serverUrl);
} catch (err) {
// The clear did not happen, and the caller has no other way to learn that.
throw err instanceof Error ? err : new Error(String(err));
}
if (params.enabled === false) {
return emptyPlan(serverUrl, { status: "skipped", reason: "disabled" });
}
try {
const { grants, failures } = await collectGrants(snapshot);
if (grants.length === 0) {
return emptyPlan(
serverUrl,
{ status: "skipped", reason: "no_tokens" },
failures,
);
}
const metadata = parseServerMetadata(snapshot.serverMetadata);
if (!metadata) {
return emptyPlan(
serverUrl,
{ status: "skipped", reason: "no_metadata" },
failures,
);
}
if (!metadata.revocation_endpoint) {
return emptyPlan(
serverUrl,
{ status: "skipped", reason: "no_endpoint" },
failures,
);
}
return {
serverUrl,
grants,
failures,
endpoint: metadata.revocation_endpoint,
supportedAuthMethods: revocationAuthMethods(metadata),
metadataIssuer: metadata.issuer,
};
} catch (err) {
// The state is already gone; a snapshot we cannot interpret is reported,
// not thrown, because there is nothing left for the caller to retry.
return emptyPlan(serverUrl, {
status: "failed",
detail: err instanceof Error ? err.message : String(err),
});
}
}
/**
* The cached authorization-server metadata from a snapshot.
*
* Read structurally rather than schema-parsed: this is a document the SDK's own
* discovery wrote and the store round-trips as-is, and only two fields are
* used. A parse failure here would lose an otherwise revocable grant.
*/
function parseServerMetadata(raw: unknown): CachedMetadata | undefined {
if (raw === null || typeof raw !== "object") return undefined;
return raw as CachedMetadata;
}
/** The three fields revocation reads out of cached AS metadata. */
interface CachedMetadata {
revocation_endpoint?: string;
revocation_endpoint_auth_methods_supported?: string[];
issuer?: string;
}
/**
* Send the requests a {@link clearAndPlanRevocation} snapshot describes.
*
* Safe to run **after** `storage.clear(serverUrl)` — that is the point: the
* plan already holds the tokens, the credentials and the endpoint, so nothing
* here reads the store, and the local clear is never waiting on the network.
*
* Best-effort throughout: every path returns a {@link TokenRevocationOutcome}
* rather than throwing, because forgetting the tokens is what the caller
* actually asked for and no failure on this leg should undo it.
*/
export async function executeOAuthRevocation(
plan: OAuthRevocationPlan,
params: ExecuteOAuthRevocationParams,
): Promise<TokenRevocationOutcome> {
const outcome = await runPlan(plan, params);
const { logger } = params;
if (outcome.status === "failed") {
logger?.warn(
{
serverUrl: plan.serverUrl,
endpoint: outcome.endpoint,
detail: outcome.detail,
},
"Token revocation failed; local OAuth state was cleared anyway",
);
} else if (outcome.status === "skipped" && outcome.reason === "no_endpoint") {
logger?.debug(
{ serverUrl: plan.serverUrl },
"Skipping token revocation: authorization server metadata has no revocation_endpoint",
);
}
return outcome;
}
/** One revocable grant held for a server, and which AS minted it. */
export interface StoredGrant {
/** Undefined for the legacy unkeyed slot, which predates issuer binding. */
issuer?: string;
token: string;
tokenTypeHint: "refresh_token" | "access_token";
/**
* The credentials to authenticate the revocation request with: the
* registration bound to this grant's issuer, falling back to the
* preconfigured (static) one.
*
* Deliberately the **reverse** of `BaseOAuthClientProvider.clientInformation`,
* which prefers the preregistered entry. That order answers "who should I
* authenticate as *now*"; revocation asks "who minted *this* token", and the
* two diverge — the store lets a preregistered client and an issuer-bound
* dynamic registration coexist, so after a server is switched from DCR to a
* configured `oauth.clientId` an older DCR grant would be revoked with the
* configured client's credentials. RFC 7009 §2.2 answers 200 for a token the
* server does not recognise as the caller's, so that reports `revoked` while
* the grant stays live and the local record is already gone.
*
* Best available evidence, not proof: the store records client information
* per issuer, not per token, so a server that re-registered dynamically under
* one issuer still cannot say which registration minted which grant. Binding
* the identity to the token at save time is the real fix and is a
* storage-shape change.
*/
clientInformation?: OAuthClientInformation;
}
/** What {@link collectGrants} found, including the slots it could not read. */
interface CollectedGrants {
grants: StoredGrant[];
/**
* One `failed` outcome per slot that could not be parsed. Kept apart from the
* grants so a single corrupt slot cannot abandon the ones that are still
* revocable — the state is gone either way, so the failure has to be reported
* *beside* the successes rather than instead of them.
*/
failures: TokenRevocationOutcome[];
}
/**
* Every grant the snapshot took, deduplicated by **issuer and token**.
*
* The snapshot is of state that has already been deleted, so this covers every
* grant the clear removed rather than just the active issuer's: a server that
* authorized against issuers A and B has two grants here, not one. Otherwise
* B would be revoked while A's grant stayed live at its authorization server —
* the same leak this feature closes, one level down.
*
* Deduplication is by issuer *and* token because a token means nothing outside
* the authorization server that minted it: two issuers that happen to mint the
* same opaque string are two grants, and collapsing them would drop the second
* before the issuer check could report it.
*
* Parsing happens here rather than in the snapshot: it is pure, so it belongs
* after the mutation — running it inside would reintroduce the `await` the
* atomic read exists to remove.
*/
async function collectGrants(
snapshot: RevocationSnapshot,
): Promise<CollectedGrants> {
const grants: StoredGrant[] = [];
const failures: TokenRevocationOutcome[] = [];
const seenKeys = new Set<string>();
// Parsed defensively: it is only a *fallback*, so a malformed preconfigured
// registration must not abort grants that carry their own valid credentials.
// Recorded and skipped, matching the per-slot salvage below.
let preregistered: OAuthClientInformation | undefined;
try {
preregistered = await parseClient(snapshot.preregisteredClientInformation);
} catch (err) {
failures.push({
status: "failed",
detail: `could not read the preconfigured client registration: ${
err instanceof Error ? err.message : String(err)
}`,
});
}
const add = async (
issuer: string | undefined,
rawTokens: unknown,
rawClient: unknown,
): Promise<void> => {
const revocable = selectRevocableToken(await parseTokens(rawTokens));
if (!revocable) return;
const key = `${issuer ?? "\u0000legacy"}\u0000${revocable.token}`;
if (seenKeys.has(key)) return;
seenKeys.add(key);
// The registration bound to THIS grant wins, with the preconfigured entry
// as the fallback — see the note on `StoredGrant.clientInformation`.
const bound = await parseClient(rawClient);
grants.push({
issuer,
...revocable,
clientInformation: bound ?? preregistered,
});
};
/** Read one slot; a slot that cannot be parsed is reported, not fatal. */
const addSafely = async (
issuer: string | undefined,
rawTokens: unknown,
rawClient: unknown,
): Promise<void> => {
try {
await add(issuer, rawTokens, rawClient);
} catch (err) {
const where =
issuer === undefined ? "the unkeyed slot" : `issuer ${issuer}`;
failures.push({
status: "failed",
detail: `could not read the stored grant for ${where}: ${
err instanceof Error ? err.message : String(err)
}`,
});
}
};
for (const [issuer, slot] of Object.entries(snapshot.byIssuer)) {
await addSafely(issuer, slot?.tokens, slot?.clientInformation);
}
await addSafely(
undefined,
snapshot.legacyTokens,
snapshot.legacyClientInformation,
);
return { grants, failures };
}
/** Parse stored tokens, or `undefined` when the slot held none. */
async function parseTokens(raw: unknown): Promise<OAuthTokens | undefined> {
return raw === undefined || raw === null
? undefined
: await OAuthTokensSchema.parseAsync(raw);
}
/** Parse stored client information, or `undefined` when the slot held none. */
async function parseClient(
raw: unknown,
): Promise<OAuthClientInformation | undefined> {
return raw === undefined || raw === null
? undefined
: await OAuthClientInformationSchema.parseAsync(raw);
}
/**
* 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" }
);
}
/** Send one plan's requests against a single shared deadline. */
async function runPlan(
plan: OAuthRevocationPlan,
params: ExecuteOAuthRevocationParams,
): Promise<TokenRevocationOutcome> {
if (plan.outcome) return plan.outcome;
const endpoint = plan.endpoint;
/* v8 ignore next -- `clearAndPlanRevocation` always sets `outcome` when it sets
no endpoint, so this is unreachable; the guard exists to narrow the type. */
if (!endpoint) return { status: "skipped", reason: "no_endpoint" };
const outcomes: TokenRevocationOutcome[] = [...plan.failures];
// ONE deadline for the whole teardown, not one per grant. `clear` deletes
// every issuer slot, so a server with N of them would otherwise block for
// N × the timeout — at which point the "short timeout" bounds a single
// request and nothing the user experiences. Grants are revoked sequentially
// on purpose (a burst of parallel requests to one authorization server is
// not a kindness), so the budget is shared instead.
const timeoutMs = params.timeoutMs ?? DEFAULT_REVOCATION_TIMEOUT_MS;
const deadlineAt = Date.now() + timeoutMs;
for (const grant of plan.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 disclose a bearer credential to a
// server that never minted it — worse than not revoking. So an issuer-bound
// grant must be able to PROVE the endpoint is its own, which means a
// metadata document carrying no `issuer` is a mismatch rather than a free
// pass: absence establishes nothing. The same reasoning rules out the
// unkeyed legacy grant just below — see there.
if (grant.issuer === undefined) {
// An unkeyed (pre-SEP-2352) grant records no authorization server at all,
// and "unknown" is not "whatever the cache currently holds": server-level
// metadata is a single slot that a later discovery overwrites, while the
// legacy token survives until the first issuer-stamped save. So the
// endpoint on hand may belong to an authorization server that never
// minted this token, and sending it there would disclose a bearer
// credential to a stranger.
//
// Refusing costs such an entry its revocation — which is exactly the
// behavior it had before this feature existed, so nothing regresses — and
// it earns revocation back the moment it is re-authorized, since that
// save is issuer-stamped. Not sending is the only answer that cannot be
// wrong.
outcomes.push({
status: "failed",
detail:
"the stored grant is not bound to an authorization server (it predates issuer binding), so it could not be matched to this revocation endpoint and was cleared without revocation — re-authorize to make future clears revocable",
});
continue;
}
if (plan.metadataIssuer !== grant.issuer) {
outcomes.push({
status: "failed",
detail:
plan.metadataIssuer === undefined
? `the cached authorization-server metadata names no issuer, so the grant bound to ${grant.issuer} could not be matched to this revocation endpoint and was cleared without revocation`
: `the cached authorization-server metadata is for ${plan.metadataIssuer}, so the grant bound to ${grant.issuer} was cleared without revocation`,
});
continue;
}
const remainingMs = deadlineAt - Date.now();
if (remainingMs <= 0) {
outcomes.push({
status: "failed",
endpoint,
detail: `the ${timeoutMs}ms revocation budget was exhausted before this grant was attempted${
grant.issuer === undefined ? "" : ` (issuer ${grant.issuer})`
}`,
});
continue;
}
outcomes.push(
await revokeToken({
endpoint,
token: grant.token,
tokenTypeHint: grant.tokenTypeHint,
clientInformation: grant.clientInformation,
supportedAuthMethods: plan.supportedAuthMethods,
fetchFn: params.fetchFn,
timeoutMs: remainingMs,
}),
);
}
return aggregateOutcomes(outcomes);
}

View file

@ -32,6 +32,26 @@ export interface SaveClientInformationOptions {
issuer?: string;
}
/**
* A server's revocation-relevant state, taken and deleted in one step by
* {@link OAuthStorage.takeRevocationSnapshot}. Values are as stored — unparsed
* — so validation can happen after the mutation rather than inside it.
*/
export interface RevocationSnapshot {
/** Credentials per authorization-server issuer (SEP-2352). */
byIssuer: Record<
string,
{ tokens?: unknown; clientInformation?: unknown } | undefined
>;
/** The legacy unkeyed slot, for entries predating issuer binding. */
legacyTokens?: unknown;
legacyClientInformation?: unknown;
/** The static/preconfigured registration, if any. */
preregisteredClientInformation?: unknown;
/** Cached authorization-server metadata, if discovery ever ran. */
serverMetadata?: unknown;
}
export interface OAuthStorage {
/**
* Optional preload of persisted state into memory. Getters and setters load
@ -182,6 +202,27 @@ export interface OAuthStorage {
*/
clearDiscoveryState(serverUrl: string): Promise<void>;
/**
* Atomically read everything RFC 7009 revocation needs, and clear the
* server's state in the same step (#2144).
*
* Split reads followed by a separate `clear` are a check-then-act: each
* `await` yields, and an OAuth completion landing in one of those gaps saves
* a fresh grant that the clear then deletes. Doing both against one
* synchronous view of the in-memory state closes that window, and the network
* requests are sent afterwards from the returned snapshot.
*
* The values are returned **unparsed**, exactly as stored. Schema validation
* is pure and belongs after the mutation — running it in between would
* reintroduce the `await` this exists to remove.
*
* ⚠️ In-process only. Nothing in this store takes a cross-process lock —
* every mutation here is a read-modify-write over a loaded snapshot — so a
* second Inspector writing concurrently can still lose an update. That is a
* property of the store, not of this method; `clear` alone has always had it.
*/
takeRevocationSnapshot(serverUrl: string): Promise<RevocationSnapshot>;
/**
* Clear all OAuth data for a server
*/