Copilot review round 17. `resolveClientInformation` mirrored `BaseOAuthClientProvider`, preferring 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 was 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 reported `revoked` while the grant stayed live and the local record was already gone — the worst available outcome. The registration bound to the grant's issuer now wins, with the preregistered entry as the fallback, which covers both directions: a token minted with the configured client leaves the issuer slot empty, since the SDK writes it only after DCR. Stated in the comment rather than implied: this is best available evidence, not proof. The store records client information per issuer, not per token, so binding identity at save time remains the real fix and is a storage-shape change beyond this PR. Also fixed a CLI test comment still describing the pre-round-13 ordering. 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>
711 lines
29 KiB
TypeScript
711 lines
29 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.
|
||
*
|
||
* 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.
|
||
*
|
||
* 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) {
|
||
// 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, and the ordering it enables is the whole
|
||
* point: plan → `storage.clear(serverUrl)` → execute. Revoking first and
|
||
* clearing afterwards delays the clear by however long the network takes, and a
|
||
* *fresh* authorization completing in that window would then be deleted by a
|
||
* clear that was reasoning about the grant it replaced. Snapshotting first
|
||
* closes that window: the clear runs against what the user asked to forget, and
|
||
* the requests go out against a copy nothing can invalidate.
|
||
*/
|
||
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,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Read every grant the impending `clear(serverUrl)` will delete, plus the
|
||
* endpoint and credentials needed to revoke them.
|
||
*
|
||
* 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. A server that never
|
||
* completed an OAuth flow has no cached metadata *and* no tokens, so it
|
||
* short-circuits either way.
|
||
*/
|
||
export async function planOAuthRevocation(
|
||
params: PlanOAuthRevocationParams,
|
||
): Promise<OAuthRevocationPlan> {
|
||
const { serverUrl, storage } = params;
|
||
if (params.enabled === false) {
|
||
return emptyPlan(serverUrl, { status: "skipped", reason: "disabled" });
|
||
}
|
||
|
||
try {
|
||
const { grants, failures } = await collectGrants(storage, serverUrl);
|
||
if (grants.length === 0) {
|
||
return emptyPlan(
|
||
serverUrl,
|
||
{ status: "skipped", reason: "no_tokens" },
|
||
failures,
|
||
);
|
||
}
|
||
|
||
const metadata = await storage.getServerMetadata(serverUrl);
|
||
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) {
|
||
// A store that cannot be read is not a reason to abandon the clear the
|
||
// user asked for.
|
||
return emptyPlan(serverUrl, {
|
||
status: "failed",
|
||
detail: err instanceof Error ? err.message : String(err),
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send the requests a {@link planOAuthRevocation} 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;
|
||
}
|
||
|
||
/**
|
||
* The client credentials to authenticate the revocation request with.
|
||
*
|
||
* Deliberately the **reverse** of `BaseOAuthClientProvider.clientInformation`,
|
||
* which prefers the preregistered (static) entry. That order answers "who
|
||
* should I authenticate as *now*"; revocation asks a different question — "who
|
||
* minted *this* token" — and the two diverge. The store lets a preregistered
|
||
* client and an issuer-bound dynamic registration coexist
|
||
* (`savePreregisteredClientInformation` does not clear the issuer slot), 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 makes a 200 the answer for a token the server does not
|
||
* recognise as the caller's, so that reports `revoked` while the grant stays
|
||
* live — the worst possible outcome, since the local record is gone.
|
||
*
|
||
* So the registration bound to this grant's `issuer` wins where the store has
|
||
* one, and the preregistered entry is the fallback. That covers both
|
||
* directions: a token minted with the configured client leaves the issuer slot
|
||
* empty (the SDK only writes it after DCR) and falls through correctly.
|
||
*
|
||
* This is 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 distinguish which registration
|
||
* minted which grant. Binding the client identity to the token at save time is
|
||
* the real fix and is a storage-shape change beyond this PR.
|
||
*/
|
||
async function resolveClientInformation(
|
||
storage: OAuthStorage,
|
||
serverUrl: string,
|
||
issuer?: string,
|
||
): Promise<OAuthClientInformation | undefined> {
|
||
return (
|
||
(issuer !== undefined
|
||
? await storage.getClientInformation(serverUrl, false, issuer)
|
||
: undefined) ?? (await storage.getClientInformation(serverUrl, true))
|
||
);
|
||
}
|
||
|
||
/** 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";
|
||
clientInformation?: OAuthClientInformation;
|
||
}
|
||
|
||
/** What {@link collectGrants} found, including the slots it could not read. */
|
||
interface CollectedGrants {
|
||
grants: StoredGrant[];
|
||
/**
|
||
* One `failed` outcome per slot whose read threw. Kept apart from the grants
|
||
* so a single corrupt slot cannot abandon the ones that are still revocable
|
||
* — the clear deletes them all either way, so the failure has to be reported
|
||
* *beside* the successes rather than instead of them.
|
||
*/
|
||
failures: TokenRevocationOutcome[];
|
||
}
|
||
|
||
/**
|
||
* Every grant `clear(serverUrl)` is about to delete.
|
||
*
|
||
* `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.
|
||
*
|
||
* Deduplication is by **issuer *and* token**, not by token alone. A token is
|
||
* only meaningful to the authorization server that minted it, so two issuers
|
||
* that happen to mint the same opaque string are two grants; collapsing them
|
||
* would drop the second before the issuer-mismatch check could even report it.
|
||
*
|
||
* The ctx-less read is included last and is the one exception: it resolves to
|
||
* the *active* issuer's slot (already collected above) or, on a pre-SEP-2352
|
||
* entry, to the legacy unkeyed token — which nothing else returns. It is
|
||
* suppressed on the issuer *stamp* the store puts on a slot-sourced value
|
||
* rather than on the token's value, because the active slot may hold client
|
||
* information without a token: the read then falls back to the legacy grant,
|
||
* and a value collision with any other issuer would drop a real grant. 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 one.
|
||
*/
|
||
async function collectGrants(
|
||
storage: OAuthStorage,
|
||
serverUrl: string,
|
||
): Promise<CollectedGrants> {
|
||
const grants: StoredGrant[] = [];
|
||
const failures: TokenRevocationOutcome[] = [];
|
||
const seenKeys = new Set<string>();
|
||
|
||
const add = async (issuer?: string): Promise<void> => {
|
||
const tokens =
|
||
issuer === undefined
|
||
? await storage.getTokens(serverUrl)
|
||
: await storage.getIssuerTokens(serverUrl, issuer);
|
||
const revocable = selectRevocableToken(tokens);
|
||
if (!revocable) return;
|
||
|
||
// `getTokens` stamps the resolved issuer onto a value it took from a
|
||
// byIssuer slot and leaves an unkeyed one unstamped (see `withIssuer` in
|
||
// oauth-storage.ts — the stamp is the key it came from), so the ctx-less
|
||
// read reports which issuer its answer belongs to, if any.
|
||
const grantIssuer =
|
||
issuer ?? (tokens as { issuer?: string } | undefined)?.issuer;
|
||
// Keyed by issuer AND token: a token means nothing outside the
|
||
// authorization server that minted it, so two issuers minting the same
|
||
// opaque value are two grants. Deduping the ctx-less read on its *stamp*
|
||
// alone would be wrong too — if `listIssuers` failed there is no enumerated
|
||
// slot behind the stamp, and skipping would leave the one readable grant
|
||
// unrevoked while `clear` deleted it.
|
||
const key = `${grantIssuer ?? "\u0000legacy"}\u0000${revocable.token}`;
|
||
if (seenKeys.has(key)) return;
|
||
seenKeys.add(key);
|
||
|
||
grants.push({
|
||
issuer: grantIssuer,
|
||
...revocable,
|
||
clientInformation: await resolveClientInformation(
|
||
storage,
|
||
serverUrl,
|
||
grantIssuer,
|
||
),
|
||
});
|
||
};
|
||
|
||
/** Read one slot; a slot that throws is reported, not fatal to the rest. */
|
||
const addSafely = async (issuer?: string): Promise<void> => {
|
||
try {
|
||
await add(issuer);
|
||
} 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)
|
||
}`,
|
||
});
|
||
}
|
||
};
|
||
|
||
// A `listIssuers` failure is fatal on its own — there is nothing to
|
||
// enumerate — but the ctx-less read below can still find a legacy grant, so
|
||
// it is recorded rather than thrown.
|
||
let issuers: string[] = [];
|
||
try {
|
||
issuers = await storage.listIssuers(serverUrl);
|
||
} catch (err) {
|
||
failures.push({
|
||
status: "failed",
|
||
detail: `could not list the stored authorization servers: ${
|
||
err instanceof Error ? err.message : String(err)
|
||
}`,
|
||
});
|
||
}
|
||
for (const issuer of issuers) {
|
||
await addSafely(issuer);
|
||
}
|
||
await addSafely();
|
||
return { grants, failures };
|
||
}
|
||
|
||
/**
|
||
* 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 -- `planOAuthRevocation` 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);
|
||
}
|