inspector/core/auth/revocation.ts
cliffhall 0ded02b34c fix(auth): revoke with the registration bound to the grant, not the current one (#2144)
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>
2026-08-28 07:01:20 -04:00

711 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
}