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:
commit
2eeab05d00
4 changed files with 8524 additions and 1598 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
743
core/auth/revocation.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue