fix(auth): clear before waiting on the network, not after (#2144)
Copilot review round 13. Three of its four findings were one root cause, and it is a design flaw in the premise this PR started from. Revoking *before* the clear meant the clear waited up to five seconds on the network — and anything that wrote OAuth state in that window was then deleted by a clear reasoning about the grant it replaced. A same-server reauthorization in the TUI (whose Disconnect/Connect accelerators stay live), a stored-only web clear for a server that becomes active mid-flight, and another CLI/TUI process writing to the shared file-backed store all hit it. `revokeStoredOAuthTokens` is therefore split into `planOAuthRevocation` and `executeOAuthRevocation`, and every caller runs plan → `storage.clear()` → execute. The snapshot still precedes the clear, because the store is where the token, the credentials and the endpoint live; but the clear no longer waits on anything. There is deliberately no single-call wrapper left, so no caller can reintroduce the racy order by accident. Fourth finding, same file: `handleConnect` now retires `clearOAuthAttemptRef` alongside `disconnectAttemptRef`. The server-name check does not cover disconnect/reconnect to the SAME server — the name still matches on the other side of it — so a clear still in flight could tear down the session the connect had just established. 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>
This commit is contained in:
parent
c0bcdc7e4a
commit
69fcdd364c
12 changed files with 447 additions and 214 deletions
18
AGENTS.md
18
AGENTS.md
|
|
@ -102,11 +102,19 @@ v2/main/
|
||||||
│ │ # answered, since that is the base its
|
│ │ # answered, since that is the base its
|
||||||
│ │ # token request is made against — #2110;
|
│ │ # token request is made against — #2110;
|
||||||
│ │ # revocation.ts RFC 7009 token revocation —
|
│ │ # revocation.ts RFC 7009 token revocation —
|
||||||
│ │ # the request the three clear paths send
|
│ │ # the request the three clear paths send.
|
||||||
│ │ # BEFORE wiping local state, since the
|
│ │ # TWO halves — planOAuthRevocation then
|
||||||
│ │ # token, the client credentials and the
|
│ │ # executeOAuthRevocation — and the order
|
||||||
│ │ # discovered `revocation_endpoint` all live
|
│ │ # between them is the contract: plan →
|
||||||
│ │ # in the store the clear empties. Names the
|
│ │ # storage.clear() → execute. The snapshot
|
||||||
|
│ │ # must precede the clear (the token, the
|
||||||
|
│ │ # client credentials and the discovered
|
||||||
|
│ │ # `revocation_endpoint` all live in the store
|
||||||
|
│ │ # it empties); the CLEAR must precede the
|
||||||
|
│ │ # network, or a fresh authorization
|
||||||
|
│ │ # completing during a 5s request is deleted
|
||||||
|
│ │ # by a clear reasoning about the grant it
|
||||||
|
│ │ # replaced. Names the
|
||||||
│ │ # REFRESH token when there is one (§2.1 asks
|
│ │ # REFRESH token when there is one (§2.1 asks
|
||||||
│ │ # the AS to invalidate the access tokens
|
│ │ # the AS to invalidate the access tokens
|
||||||
│ │ # under the same grant, so one request
|
│ │ # under the same grant, so one request
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import {
|
||||||
} from "@inspector/core/auth/node/storage-node.js";
|
} from "@inspector/core/auth/node/storage-node.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_REVOCATION_TIMEOUT_MS,
|
DEFAULT_REVOCATION_TIMEOUT_MS,
|
||||||
revokeStoredOAuthTokens,
|
executeOAuthRevocation,
|
||||||
|
planOAuthRevocation,
|
||||||
|
type OAuthRevocationPlan,
|
||||||
type TokenRevocationOutcome,
|
type TokenRevocationOutcome,
|
||||||
} from "@inspector/core/auth/revocation.js";
|
} from "@inspector/core/auth/revocation.js";
|
||||||
import { createProxyFetch } from "@inspector/core/mcp/node/proxyFetch.js";
|
import { createProxyFetch } from "@inspector/core/mcp/node/proxyFetch.js";
|
||||||
|
|
@ -36,22 +38,27 @@ export async function clearStoredAuthForRelogin(
|
||||||
const raw = serverUrl.trim();
|
const raw = serverUrl.trim();
|
||||||
const normalized = normalizeServerUrl(raw);
|
const normalized = normalizeServerUrl(raw);
|
||||||
const storage = new NodeOAuthStorage();
|
const storage = new NodeOAuthStorage();
|
||||||
// RFC 7009 (#2144): revoke before the clear, since the token, the client
|
// RFC 7009 (#2144), ordered snapshot -> clear -> revoke. Everything the
|
||||||
// credentials and the discovered `revocation_endpoint` all live in the store
|
// requests need is read first, because the clear empties the store; but the
|
||||||
// this is about to empty. Best-effort — the outcome is returned for the
|
// clear then runs immediately rather than behind the network. Waiting would
|
||||||
// caller to report, never thrown, so `--relogin` succeeds regardless.
|
// hold this process's in-memory view of a *shared*, file-backed store for up
|
||||||
|
// to five seconds, and another CLI/TUI writing a fresh grant in that window
|
||||||
|
// would be erased by the clear that followed.
|
||||||
//
|
//
|
||||||
// Both spellings are cleared below, so both are revoked from — a stale entry
|
// Both spellings are cleared, so both are planned from — a stale entry under
|
||||||
// under the other key is a live grant at the authorization server, and
|
// the other key is a live grant at the authorization server, and deleting it
|
||||||
// deleting it locally without revoking is exactly the leak this closes. The
|
// locally without revoking is exactly the leak this closes. The normalised
|
||||||
// normalised key goes first because that is the precedence
|
// key goes first because that is the precedence `findStoredServerState` reads
|
||||||
// `findStoredServerState` reads with, so the grant actually in use is the one
|
// with, so the grant actually in use is the one whose outcome is reported.
|
||||||
// whose outcome is reported. They are deliberately not deduplicated; see
|
// They are deliberately not deduplicated; see `sendPlans`.
|
||||||
// `revokeStoredKeys`.
|
const keys = normalized === raw ? [raw] : [normalized, raw];
|
||||||
const revocation =
|
const plans =
|
||||||
options?.revoke === false
|
options?.revoke === false
|
||||||
? undefined
|
? []
|
||||||
: await revokeStoredKeys(storage, [normalized, raw]);
|
: await Promise.all(
|
||||||
|
keys.map((key) => planOAuthRevocation({ serverUrl: key, storage })),
|
||||||
|
);
|
||||||
|
|
||||||
await storage.clear(raw);
|
await storage.clear(raw);
|
||||||
if (normalized !== raw) {
|
if (normalized !== raw) {
|
||||||
await storage.clear(normalized);
|
await storage.clear(normalized);
|
||||||
|
|
@ -59,58 +66,54 @@ export async function clearStoredAuthForRelogin(
|
||||||
// Drop the in-process singleton so the next connect cannot reuse a cleared
|
// Drop the in-process singleton so the next connect cannot reuse a cleared
|
||||||
// entry from the NodeOAuthStorage cache.
|
// entry from the NodeOAuthStorage cache.
|
||||||
resetNodeOAuthStorageCache();
|
resetNodeOAuthStorageCache();
|
||||||
return revocation;
|
|
||||||
|
return plans.length > 0 ? sendPlans(plans) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke every grant held under `keys`, in order, and report the outcome that
|
* Send each plan's requests, in order, and report the outcome that matters most.
|
||||||
* matters most.
|
|
||||||
*
|
*
|
||||||
* Both keys are about to be deleted, so both are revoked from: a stale entry
|
* Both key spellings are cleared, so both are planned from: a stale entry under
|
||||||
* under the other spelling is still a live grant at the authorization server,
|
* the other spelling is still a live grant at the authorization server, and
|
||||||
* and deleting it locally without revoking is the leak this whole change
|
* deleting it locally without revoking is the leak this whole change closes.
|
||||||
* closes.
|
|
||||||
*
|
*
|
||||||
* There is deliberately **no** cross-key deduplication. It looked cheap — the
|
* There is deliberately **no** cross-key deduplication. It looked cheap — the
|
||||||
* two keys are usually two spellings of one server — but it can only be done
|
* two keys are usually two spellings of one server — but it can only be done by
|
||||||
* by pre-reading a single token, and `revokeStoredOAuthTokens` enumerates every
|
* pre-reading a single token, and a plan enumerates every issuer slot under a
|
||||||
* issuer slot under a key. So a shared *active* token would have skipped the
|
* key. So a shared *active* token would have skipped the second key entirely,
|
||||||
* second key entirely, taking any additional issuer-bound grant under it with
|
* taking any additional issuer-bound grant under it with the local delete. A
|
||||||
* the local delete. A duplicate RFC 7009 request is harmless (§2.2 makes an
|
* duplicate RFC 7009 request is harmless (§2.2 makes an unknown token a
|
||||||
* unknown token a success), which is a much better trade than a missed one.
|
* success), which is a much better trade than a missed one.
|
||||||
|
*
|
||||||
|
* One budget across both plans, for the same reason a plan shares one across
|
||||||
|
* its grants: two keys would otherwise double the wait.
|
||||||
*
|
*
|
||||||
* Reporting prefers a **failure** over any success — a grant still live at the
|
* Reporting prefers a **failure** over any success — a grant still live at the
|
||||||
* authorization server is what the user needs to hear about, and a success
|
* authorization server is what the user needs to hear about, and a success
|
||||||
* would otherwise silence the warning. Failing that it is the first key's
|
* would otherwise silence the warning. Failing that it is the first plan's
|
||||||
* outcome that was not "nothing to do", which with `keys` in
|
* outcome that was not "nothing to do", which with the keys in
|
||||||
* `findStoredServerState` precedence means the grant the CLI would actually
|
* `findStoredServerState` precedence means the grant the CLI would actually
|
||||||
* have connected with. When no key holds a token, the last "nothing to do"
|
* have connected with. When no key held a token, the last "nothing to do"
|
||||||
* answer is returned so the caller can still tell "no tokens" from "this
|
* answer is returned so the caller can still tell "no tokens" from "this
|
||||||
* authorization server advertises no revocation endpoint".
|
* authorization server advertises no revocation endpoint".
|
||||||
*/
|
*/
|
||||||
async function revokeStoredKeys(
|
async function sendPlans(
|
||||||
storage: NodeOAuthStorage,
|
plans: OAuthRevocationPlan[],
|
||||||
keys: string[],
|
|
||||||
): Promise<TokenRevocationOutcome | undefined> {
|
): Promise<TokenRevocationOutcome | undefined> {
|
||||||
const fetchFn = createProxyFetch() ?? fetch;
|
const fetchFn = createProxyFetch() ?? fetch;
|
||||||
// One budget across both keys, for the same reason `revokeStoredOAuthTokens`
|
|
||||||
// shares one across grants: two keys would otherwise double the wait a user
|
|
||||||
// feels before `--relogin` gets on with the local delete.
|
|
||||||
const deadlineAt = Date.now() + DEFAULT_REVOCATION_TIMEOUT_MS;
|
const deadlineAt = Date.now() + DEFAULT_REVOCATION_TIMEOUT_MS;
|
||||||
let reported: TokenRevocationOutcome | undefined;
|
let reported: TokenRevocationOutcome | undefined;
|
||||||
let lastSkip: TokenRevocationOutcome | undefined;
|
let lastSkip: TokenRevocationOutcome | undefined;
|
||||||
for (const key of new Set(keys)) {
|
for (const plan of plans) {
|
||||||
const remainingMs = deadlineAt - Date.now();
|
const remainingMs = deadlineAt - Date.now();
|
||||||
if (remainingMs <= 0) {
|
if (remainingMs <= 0) {
|
||||||
reported ??= {
|
reported ??= {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
detail: `the ${DEFAULT_REVOCATION_TIMEOUT_MS}ms revocation budget was exhausted before "${key}" was attempted`,
|
detail: `the ${DEFAULT_REVOCATION_TIMEOUT_MS}ms revocation budget was exhausted before "${plan.serverUrl}" was attempted`,
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const outcome = await revokeStoredOAuthTokens({
|
const outcome = await executeOAuthRevocation(plan, {
|
||||||
serverUrl: key,
|
|
||||||
storage,
|
|
||||||
fetchFn,
|
fetchFn,
|
||||||
timeoutMs: remainingMs,
|
timeoutMs: remainingMs,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -796,6 +796,11 @@ function App({
|
||||||
// A connect attempt supersedes whatever the last disconnect reported —
|
// A connect attempt supersedes whatever the last disconnect reported —
|
||||||
// including one still in flight, which is what the counter bump retires.
|
// including one still in flight, which is what the counter bump retires.
|
||||||
disconnectAttemptRef.current++;
|
disconnectAttemptRef.current++;
|
||||||
|
// And whatever a pending clear was going to do (#2144). The server-name
|
||||||
|
// check alone does not cover disconnect/reconnect to the SAME server: the
|
||||||
|
// name still matches on the other side of it, so a clear still in flight
|
||||||
|
// would tear down the session this connect just established.
|
||||||
|
clearOAuthAttemptRef.current++;
|
||||||
setDisconnectError(null);
|
setDisconnectError(null);
|
||||||
|
|
||||||
const finishConnect = async () => {
|
const finishConnect = async () => {
|
||||||
|
|
|
||||||
|
|
@ -80,10 +80,11 @@ describe("clearServerOAuthState", () => {
|
||||||
expect(clearOAuthTokens).toHaveBeenCalledWith({ revoke: false });
|
expect(clearOAuthTokens).toHaveBeenCalledWith({ revoke: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
// #2144 — the non-active path revokes from the store directly. The whole
|
// #2144 — the non-active path revokes from the store directly. The snapshot
|
||||||
// point is that it happens *before* the clear: after it there is no token,
|
// is taken before the clear (after it there is no token, client id or cached
|
||||||
// no client id and no cached metadata left to build a request from.
|
// metadata to build a request from), but the clear itself runs before the
|
||||||
it("revokes before clearing when this server is not the active connection", async () => {
|
// network — so by the time the request goes out the store is already empty.
|
||||||
|
it("clears before sending, using the snapshot it took first", async () => {
|
||||||
await storage.saveTokens(SERVER_URL, {
|
await storage.saveTokens(SERVER_URL, {
|
||||||
access_token: "tok",
|
access_token: "tok",
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
|
|
@ -97,7 +98,7 @@ describe("clearServerOAuthState", () => {
|
||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
});
|
});
|
||||||
|
|
||||||
let tokensAtRequestTime: unknown;
|
let tokensAtRequestTime: unknown = "unset";
|
||||||
const fetchFn = vi.fn<typeof fetch>(async () => {
|
const fetchFn = vi.fn<typeof fetch>(async () => {
|
||||||
tokensAtRequestTime = await storage.getTokens(SERVER_URL);
|
tokensAtRequestTime = await storage.getTokens(SERVER_URL);
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
|
|
@ -115,7 +116,9 @@ describe("clearServerOAuthState", () => {
|
||||||
tokenTypeHint: "refresh_token",
|
tokenTypeHint: "refresh_token",
|
||||||
endpoint: "https://as.example.com/revoke",
|
endpoint: "https://as.example.com/revoke",
|
||||||
});
|
});
|
||||||
expect(tokensAtRequestTime).toBeDefined();
|
// The store was already empty when the request went out — that is the
|
||||||
|
// ordering, and it is what stops a concurrently-written grant being wiped.
|
||||||
|
expect(tokensAtRequestTime).toBeUndefined();
|
||||||
expect(await storage.getTokens(SERVER_URL)).toBeUndefined();
|
expect(await storage.getTokens(SERVER_URL)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
revokeStoredOAuthTokens,
|
executeOAuthRevocation,
|
||||||
|
planOAuthRevocation,
|
||||||
type TokenRevocationOutcome,
|
type TokenRevocationOutcome,
|
||||||
} from "@inspector/core/auth/revocation.js";
|
} from "@inspector/core/auth/revocation.js";
|
||||||
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
|
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
|
||||||
|
|
@ -43,7 +44,7 @@ export interface ClearServerOAuthStateResult {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear persisted OAuth state (tokens, DCR/CIMD client id, PKCE, etc.) for an
|
* Clear persisted OAuth state (tokens, DCR/CIMD client id, PKCE, etc.) for an
|
||||||
* HTTP MCP server, revoking the grant at the authorization server first. When
|
* HTTP MCP server, revoking the grant at the authorization server. When
|
||||||
* clearing the active connection, uses the live client so in-memory flow state
|
* clearing the active connection, uses the live client so in-memory flow state
|
||||||
* is reset too.
|
* is reset too.
|
||||||
*
|
*
|
||||||
|
|
@ -71,14 +72,20 @@ export async function clearServerOAuthState(
|
||||||
// No proxied fetch on hand means no request we could usefully make, so the
|
// No proxied fetch on hand means no request we could usefully make, so the
|
||||||
// leg is reported as skipped rather than attempted against the page origin.
|
// leg is reported as skipped rather than attempted against the page origin.
|
||||||
const fetchFn = params.fetchFn;
|
const fetchFn = params.fetchFn;
|
||||||
const revocation: TokenRevocationOutcome =
|
// Snapshot → clear → revoke. The clear must not wait on the network: this
|
||||||
revoke && fetchFn
|
// server can be inactive when the call starts and complete a *fresh*
|
||||||
? await revokeStoredOAuthTokens({
|
// authorization while the request is in flight, at which point an unconditional
|
||||||
serverUrl,
|
// clear afterwards would delete the new credentials. The session checks in
|
||||||
storage: params.oauthStorage,
|
// `useOAuthRecovery` run after this helper returns and cannot protect the
|
||||||
fetchFn,
|
// store, so the ordering is what does (#2144).
|
||||||
})
|
const plan = await planOAuthRevocation({
|
||||||
: { status: "skipped", reason: "disabled" };
|
serverUrl,
|
||||||
|
storage: params.oauthStorage,
|
||||||
|
enabled: revoke && fetchFn !== undefined,
|
||||||
|
});
|
||||||
await params.oauthStorage.clear(serverUrl);
|
await params.oauthStorage.clear(serverUrl);
|
||||||
|
const revocation: TokenRevocationOutcome = fetchFn
|
||||||
|
? await executeOAuthRevocation(plan, { fetchFn })
|
||||||
|
: { status: "skipped", reason: "disabled" };
|
||||||
return { cleared: true, revocation };
|
return { cleared: true, revocation };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ import {
|
||||||
aggregateOutcomes,
|
aggregateOutcomes,
|
||||||
buildRevocationRequest,
|
buildRevocationRequest,
|
||||||
revocationAuthMethods,
|
revocationAuthMethods,
|
||||||
revokeStoredOAuthTokens,
|
executeOAuthRevocation,
|
||||||
|
planOAuthRevocation,
|
||||||
revokeToken,
|
revokeToken,
|
||||||
selectRevocableToken,
|
selectRevocableToken,
|
||||||
} from "@inspector/core/auth/revocation.js";
|
} from "@inspector/core/auth/revocation.js";
|
||||||
import type { InspectorLogger } from "@inspector/core/logging/index.js";
|
import type { InspectorLogger } from "@inspector/core/logging/index.js";
|
||||||
|
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
|
||||||
|
|
||||||
/** A fully-typed `InspectorLogger` double, so the mock's shape is checked. */
|
/** A fully-typed `InspectorLogger` double, so the mock's shape is checked. */
|
||||||
function fakeLogger(): InspectorLogger {
|
function fakeLogger(): InspectorLogger {
|
||||||
|
|
@ -339,7 +341,32 @@ describe("revokeToken", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("revokeStoredOAuthTokens", () => {
|
/**
|
||||||
|
* Compose the two halves the way every caller does, minus the `storage.clear`
|
||||||
|
* between them — these tests are about what is planned and sent, and the
|
||||||
|
* ordering guarantee itself is asserted separately below.
|
||||||
|
*/
|
||||||
|
async function revokeStoredOAuthTokens(params: {
|
||||||
|
serverUrl: string;
|
||||||
|
storage: BrowserOAuthStorage;
|
||||||
|
fetchFn: typeof fetch;
|
||||||
|
enabled?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
logger?: InspectorLogger;
|
||||||
|
}): Promise<TokenRevocationOutcome> {
|
||||||
|
const plan = await planOAuthRevocation({
|
||||||
|
serverUrl: params.serverUrl,
|
||||||
|
storage: params.storage,
|
||||||
|
enabled: params.enabled,
|
||||||
|
});
|
||||||
|
return executeOAuthRevocation(plan, {
|
||||||
|
fetchFn: params.fetchFn,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
logger: params.logger,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("revokeStoredOAuthTokens (plan + execute)", () => {
|
||||||
let storage: BrowserOAuthStorage;
|
let storage: BrowserOAuthStorage;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
@ -890,3 +917,87 @@ describe("revokeStoredOAuthTokens", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The reason the API is two halves rather than one call (#2144, review round
|
||||||
|
// 13). Revoking first and clearing afterwards delays the clear by however long
|
||||||
|
// the network takes, and a FRESH authorization completing in that window is
|
||||||
|
// then deleted by a clear reasoning about the grant it replaced.
|
||||||
|
describe("plan / clear / execute ordering", () => {
|
||||||
|
let storage: BrowserOAuthStorage;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storage = new BrowserOAuthStorage();
|
||||||
|
await storage.clear(SERVER_URL);
|
||||||
|
await storage.saveServerMetadata(
|
||||||
|
SERVER_URL,
|
||||||
|
metadata({ issuer: "https://as.example.com" }),
|
||||||
|
);
|
||||||
|
await storage.saveTokens(
|
||||||
|
SERVER_URL,
|
||||||
|
{ access_token: "old", token_type: "Bearer", refresh_token: "old-r" },
|
||||||
|
{ issuer: "https://as.example.com" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not delete a grant written while the request is in flight", async () => {
|
||||||
|
let releaseRequest: () => void = () => {};
|
||||||
|
const inFlight = new Promise<void>((resolve) => {
|
||||||
|
releaseRequest = resolve;
|
||||||
|
});
|
||||||
|
const fetchFn = vi.fn<typeof fetch>(async () => {
|
||||||
|
await inFlight;
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The caller's real sequence: snapshot, clear, then send.
|
||||||
|
const plan = await planOAuthRevocation({ serverUrl: SERVER_URL, storage });
|
||||||
|
await storage.clear(SERVER_URL);
|
||||||
|
const sending = executeOAuthRevocation(plan, { fetchFn });
|
||||||
|
|
||||||
|
// A fresh authorization lands while the request is still out.
|
||||||
|
await storage.saveTokens(
|
||||||
|
SERVER_URL,
|
||||||
|
{ access_token: "new", token_type: "Bearer", refresh_token: "new-r" },
|
||||||
|
{ issuer: "https://as.example.com" },
|
||||||
|
);
|
||||||
|
|
||||||
|
releaseRequest();
|
||||||
|
await sending;
|
||||||
|
|
||||||
|
// The new grant survived, and the OLD token is what was revoked.
|
||||||
|
expect((await storage.getTokens(SERVER_URL))?.access_token).toBe("new");
|
||||||
|
expect(
|
||||||
|
new URLSearchParams(String(fetchFn.mock.calls[0]![1]!.body)).get("token"),
|
||||||
|
).toBe("old-r");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the snapshot even though the store was emptied first", async () => {
|
||||||
|
const fetchFn = vi.fn<typeof fetch>(
|
||||||
|
async () => new Response(null, { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan = await planOAuthRevocation({ serverUrl: SERVER_URL, storage });
|
||||||
|
await storage.clear(SERVER_URL);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
executeOAuthRevocation(plan, { fetchFn }),
|
||||||
|
).resolves.toMatchObject({ status: "revoked" });
|
||||||
|
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plans nothing when revocation is disabled", async () => {
|
||||||
|
const plan = await planOAuthRevocation({
|
||||||
|
serverUrl: SERVER_URL,
|
||||||
|
storage,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
const fetchFn = vi.fn<typeof fetch>();
|
||||||
|
|
||||||
|
expect(plan.grants).toHaveLength(0);
|
||||||
|
await expect(executeOAuthRevocation(plan, { fetchFn })).resolves.toEqual({
|
||||||
|
status: "skipped",
|
||||||
|
reason: "disabled",
|
||||||
|
});
|
||||||
|
expect(fetchFn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -199,10 +199,12 @@ describe("OAuthManager", () => {
|
||||||
expect(manager.getOAuthFlowStep()).toBeUndefined();
|
expect(manager.getOAuthFlowStep()).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
// #2144 — the ordering is the contract, not an implementation detail: the
|
// #2144 — the ordering is the contract, not an implementation detail. The
|
||||||
// revocation request is built from the token, the client id and the cached
|
// request is built from the token, the client id and the cached metadata
|
||||||
// metadata that `clear` is about to delete.
|
// `clear` deletes, so the snapshot has to be taken first; but the clear
|
||||||
it("revokes at the authorization server before clearing local state", async () => {
|
// must then run BEFORE the network, or a fresh authorization completing
|
||||||
|
// during it would be deleted by a clear reasoning about the old grant.
|
||||||
|
it("clears local state before waiting on the revocation request", async () => {
|
||||||
const params = createMockParams();
|
const params = createMockParams();
|
||||||
const storage = params.initialConfig.storage!;
|
const storage = params.initialConfig.storage!;
|
||||||
vi.mocked(storage.getTokens).mockResolvedValue({
|
vi.mocked(storage.getTokens).mockResolvedValue({
|
||||||
|
|
@ -234,7 +236,7 @@ describe("OAuthManager", () => {
|
||||||
status: "revoked",
|
status: "revoked",
|
||||||
tokenTypeHint: "refresh_token",
|
tokenTypeHint: "refresh_token",
|
||||||
});
|
});
|
||||||
expect(order).toEqual(["revoke", "clear"]);
|
expect(order).toEqual(["clear", "revoke"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears local state even when the revocation request fails", async () => {
|
it("clears local state even when the revocation request fails", async () => {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,11 @@ import {
|
||||||
waitForOAuthWellKnown,
|
waitForOAuthWellKnown,
|
||||||
} from "@modelcontextprotocol/inspector-test-server";
|
} from "@modelcontextprotocol/inspector-test-server";
|
||||||
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
|
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
|
||||||
import { revokeStoredOAuthTokens } from "@inspector/core/auth/revocation.js";
|
import {
|
||||||
|
executeOAuthRevocation,
|
||||||
|
planOAuthRevocation,
|
||||||
|
} from "@inspector/core/auth/revocation.js";
|
||||||
|
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
|
||||||
import type { OAuthMetadata } from "@modelcontextprotocol/client";
|
import type { OAuthMetadata } from "@modelcontextprotocol/client";
|
||||||
|
|
||||||
const CLIENT_ID = "test-2144-revocation";
|
const CLIENT_ID = "test-2144-revocation";
|
||||||
|
|
@ -192,6 +196,18 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
||||||
expect(await tokenAccepted(tokens.access_token)).toBe(true);
|
expect(await tokenAccepted(tokens.access_token)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller's real sequence — snapshot, clear, send — so this exercises the
|
||||||
|
* ordering the product uses rather than a convenience wrapper.
|
||||||
|
*/
|
||||||
|
async function clearAndRevoke(
|
||||||
|
storage: BrowserOAuthStorage,
|
||||||
|
): Promise<TokenRevocationOutcome> {
|
||||||
|
const plan = await planOAuthRevocation({ serverUrl, storage });
|
||||||
|
await storage.clear(serverUrl);
|
||||||
|
return executeOAuthRevocation(plan, { fetchFn: fetch });
|
||||||
|
}
|
||||||
|
|
||||||
it("advertises a revocation endpoint", () => {
|
it("advertises a revocation endpoint", () => {
|
||||||
expect(metadata.revocation_endpoint).toBe(`${serverUrl}/oauth/revoke`);
|
expect(metadata.revocation_endpoint).toBe(`${serverUrl}/oauth/revoke`);
|
||||||
});
|
});
|
||||||
|
|
@ -202,11 +218,7 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
||||||
const tokens = await authorize();
|
const tokens = await authorize();
|
||||||
expect(await tokenAccepted(tokens.access_token)).toBe(true);
|
expect(await tokenAccepted(tokens.access_token)).toBe(true);
|
||||||
|
|
||||||
const outcome = await revokeStoredOAuthTokens({
|
const outcome = await clearAndRevoke(await seededStorage(tokens));
|
||||||
serverUrl,
|
|
||||||
storage: await seededStorage(tokens),
|
|
||||||
fetchFn: fetch,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(outcome).toMatchObject({
|
expect(outcome).toMatchObject({
|
||||||
status: "revoked",
|
status: "revoked",
|
||||||
|
|
@ -219,11 +231,9 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
||||||
|
|
||||||
it("revokes an access token when no refresh token was issued", async () => {
|
it("revokes an access token when no refresh token was issued", async () => {
|
||||||
const tokens = await authorize();
|
const tokens = await authorize();
|
||||||
const outcome = await revokeStoredOAuthTokens({
|
const outcome = await clearAndRevoke(
|
||||||
serverUrl,
|
await seededStorage({ access_token: tokens.access_token }),
|
||||||
storage: await seededStorage({ access_token: tokens.access_token }),
|
);
|
||||||
fetchFn: fetch,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(outcome).toMatchObject({
|
expect(outcome).toMatchObject({
|
||||||
status: "revoked",
|
status: "revoked",
|
||||||
|
|
@ -236,8 +246,8 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
||||||
// server has already expired must not be reported as a failure.
|
// server has already expired must not be reported as a failure.
|
||||||
it("treats an already-unknown token as revoked", async () => {
|
it("treats an already-unknown token as revoked", async () => {
|
||||||
const storage = await seededStorage({ access_token: "never-issued" });
|
const storage = await seededStorage({ access_token: "never-issued" });
|
||||||
await expect(
|
await expect(clearAndRevoke(storage)).resolves.toMatchObject({
|
||||||
revokeStoredOAuthTokens({ serverUrl, storage, fetchFn: fetch }),
|
status: "revoked",
|
||||||
).resolves.toMatchObject({ status: "revoked" });
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -140,13 +140,16 @@ export {
|
||||||
aggregateOutcomes,
|
aggregateOutcomes,
|
||||||
buildRevocationRequest,
|
buildRevocationRequest,
|
||||||
revocationAuthMethods,
|
revocationAuthMethods,
|
||||||
revokeStoredOAuthTokens,
|
executeOAuthRevocation,
|
||||||
|
planOAuthRevocation,
|
||||||
revokeToken,
|
revokeToken,
|
||||||
selectRevocableToken,
|
selectRevocableToken,
|
||||||
} from "./revocation.js";
|
} from "./revocation.js";
|
||||||
export type {
|
export type {
|
||||||
|
ExecuteOAuthRevocationParams,
|
||||||
|
OAuthRevocationPlan,
|
||||||
|
PlanOAuthRevocationParams,
|
||||||
RevocationRequestParams,
|
RevocationRequestParams,
|
||||||
RevokeStoredOAuthTokensParams,
|
|
||||||
RevokeTokenParams,
|
RevokeTokenParams,
|
||||||
TokenRevocationOutcome,
|
TokenRevocationOutcome,
|
||||||
TokenRevocationSkipReason,
|
TokenRevocationSkipReason,
|
||||||
|
|
|
||||||
|
|
@ -270,54 +270,157 @@ async function withDeadline<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RevokeStoredOAuthTokensParams {
|
export interface PlanOAuthRevocationParams {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
storage: OAuthStorage;
|
storage: OAuthStorage;
|
||||||
fetchFn: typeof fetch;
|
|
||||||
/**
|
/**
|
||||||
* `false` skips the request entirely — the deliberate case from #2144, where
|
* `false` plans nothing — the deliberate case from #2144, where a user wants
|
||||||
* a user wants to watch a server cope with a client that walks off still
|
* to watch a server cope with a client that walks off still holding live
|
||||||
* holding live tokens.
|
* tokens.
|
||||||
*/
|
*/
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteOAuthRevocationParams {
|
||||||
|
fetchFn: typeof fetch;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
logger?: InspectorLogger;
|
logger?: InspectorLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke the tokens the Inspector holds for `serverUrl`, reading everything it
|
* Everything the revocation requests need, read out of the store **before** the
|
||||||
* needs out of the OAuth store.
|
* local clear empties it.
|
||||||
*
|
*
|
||||||
* Called immediately **before** the local clear, since the store is where the
|
* Split from the sending on purpose, and the ordering it enables is the whole
|
||||||
* tokens, the client credentials, and the discovered `revocation_endpoint` all
|
* point: plan → `storage.clear(serverUrl)` → execute. Revoking first and
|
||||||
* live — after the clear there is nothing left to revoke with. Every grant the
|
* clearing afterwards delays the clear by however long the network takes, and a
|
||||||
* clear will delete is covered, not just the active issuer's: see
|
* *fresh* authorization completing in that window would then be deleted by a
|
||||||
* {@link collectGrants}.
|
* 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 metadata comes from the cache the OAuth flow already populated rather
|
* the requests go out against a copy nothing can invalidate.
|
||||||
* than from a fresh discovery round-trip: the tokens being revoked were minted
|
|
||||||
* by that same authorization server, so its cached document is the document
|
|
||||||
* that describes them, and re-discovering would add two network legs to a
|
|
||||||
* teardown for no new information. A server that has never completed an OAuth
|
|
||||||
* flow has no cached metadata *and* no tokens, so it short-circuits either way.
|
|
||||||
*/
|
*/
|
||||||
export async function revokeStoredOAuthTokens(
|
export interface OAuthRevocationPlan {
|
||||||
params: RevokeStoredOAuthTokensParams,
|
serverUrl: string;
|
||||||
): Promise<TokenRevocationOutcome> {
|
grants: StoredGrant[];
|
||||||
const { serverUrl, logger } = params;
|
/** 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) {
|
if (params.enabled === false) {
|
||||||
return { status: "skipped", reason: "disabled" };
|
return emptyPlan(serverUrl, { status: "skipped", reason: "disabled" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const outcome = await computeOutcome(params);
|
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") {
|
if (outcome.status === "failed") {
|
||||||
logger?.warn(
|
logger?.warn(
|
||||||
{ serverUrl, endpoint: outcome.endpoint, detail: outcome.detail },
|
{
|
||||||
"Token revocation failed; clearing local OAuth state anyway",
|
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") {
|
} else if (outcome.status === "skipped" && outcome.reason === "no_endpoint") {
|
||||||
logger?.debug(
|
logger?.debug(
|
||||||
{ serverUrl },
|
{ serverUrl: plan.serverUrl },
|
||||||
"Skipping token revocation: authorization server metadata has no revocation_endpoint",
|
"Skipping token revocation: authorization server metadata has no revocation_endpoint",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -345,7 +448,7 @@ async function resolveClientInformation(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One revocable grant held for a server, and which AS minted it. */
|
/** One revocable grant held for a server, and which AS minted it. */
|
||||||
interface StoredGrant {
|
export interface StoredGrant {
|
||||||
/** Undefined for the legacy unkeyed slot, which predates issuer binding. */
|
/** Undefined for the legacy unkeyed slot, which predates issuer binding. */
|
||||||
issuer?: string;
|
issuer?: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
|
@ -493,100 +596,71 @@ export function aggregateOutcomes(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function computeOutcome(
|
/** Send one plan's requests against a single shared deadline. */
|
||||||
params: RevokeStoredOAuthTokensParams,
|
async function runPlan(
|
||||||
|
plan: OAuthRevocationPlan,
|
||||||
|
params: ExecuteOAuthRevocationParams,
|
||||||
): Promise<TokenRevocationOutcome> {
|
): Promise<TokenRevocationOutcome> {
|
||||||
const { serverUrl, storage, fetchFn } = params;
|
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" };
|
||||||
|
|
||||||
try {
|
const outcomes: TokenRevocationOutcome[] = [...plan.failures];
|
||||||
// Grants first. They are the cheapest disqualifier, and a server with no
|
// ONE deadline for the whole teardown, not one per grant. `clear` deletes
|
||||||
// stored grant should not provoke a metadata read at all.
|
// 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
|
||||||
// `failures` seeds `outcomes` rather than short-circuiting: a slot that
|
// request and nothing the user experiences. Grants are revoked sequentially
|
||||||
// could not be read is still about to be deleted, so it has to be reported
|
// on purpose (a burst of parallel requests to one authorization server is
|
||||||
// *alongside* whatever the readable grants do, not instead of them.
|
// not a kindness), so the budget is shared instead.
|
||||||
const { grants, failures } = await collectGrants(storage, serverUrl);
|
const timeoutMs = params.timeoutMs ?? DEFAULT_REVOCATION_TIMEOUT_MS;
|
||||||
if (grants.length === 0) {
|
const deadlineAt = Date.now() + timeoutMs;
|
||||||
return failures.length > 0
|
|
||||||
? aggregateOutcomes(failures)
|
for (const grant of plan.grants) {
|
||||||
: { status: "skipped", reason: "no_tokens" };
|
// 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. Only a legacy unkeyed grant proceeds
|
||||||
|
// without the comparison, since nothing binds it to a different
|
||||||
|
// authorization server in the first place.
|
||||||
|
if (grant.issuer !== undefined && 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 metadata = await storage.getServerMetadata(serverUrl);
|
const remainingMs = deadlineAt - Date.now();
|
||||||
if (!metadata) {
|
if (remainingMs <= 0) {
|
||||||
return aggregateOutcomes([
|
outcomes.push({
|
||||||
...failures,
|
status: "failed",
|
||||||
{ status: "skipped", reason: "no_metadata" },
|
endpoint,
|
||||||
]);
|
detail: `the ${timeoutMs}ms revocation budget was exhausted before this grant was attempted${
|
||||||
}
|
grant.issuer === undefined ? "" : ` (issuer ${grant.issuer})`
|
||||||
const endpoint = metadata.revocation_endpoint;
|
}`,
|
||||||
if (!endpoint) {
|
});
|
||||||
return aggregateOutcomes([
|
continue;
|
||||||
...failures,
|
|
||||||
{ status: "skipped", reason: "no_endpoint" },
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const supportedAuthMethods = revocationAuthMethods(metadata);
|
outcomes.push(
|
||||||
const outcomes: TokenRevocationOutcome[] = [...failures];
|
await revokeToken({
|
||||||
// ONE deadline for the whole teardown, not one per grant. `clear` deletes
|
endpoint,
|
||||||
// every issuer slot, so a server with N of them would otherwise block the
|
token: grant.token,
|
||||||
// disconnect for N × the timeout — at which point the "short timeout"
|
tokenTypeHint: grant.tokenTypeHint,
|
||||||
// bounds a single request and nothing the user experiences. Grants are
|
clientInformation: grant.clientInformation,
|
||||||
// revoked sequentially on purpose (a burst of parallel requests to one
|
supportedAuthMethods: plan.supportedAuthMethods,
|
||||||
// authorization server is not a kindness), so the budget is shared instead.
|
fetchFn: params.fetchFn,
|
||||||
const timeoutMs = params.timeoutMs ?? DEFAULT_REVOCATION_TIMEOUT_MS;
|
timeoutMs: remainingMs,
|
||||||
const deadlineAt = Date.now() + timeoutMs;
|
}),
|
||||||
for (const grant of grants) {
|
);
|
||||||
// Metadata is cached once per server, not per issuer, so it describes
|
|
||||||
// whichever authorization server was discovered last. Sending another
|
|
||||||
// issuer's token to *this* endpoint would 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. Only a legacy unkeyed
|
|
||||||
// grant proceeds without the comparison, since nothing binds it to a
|
|
||||||
// different authorization server in the first place.
|
|
||||||
if (grant.issuer !== undefined && metadata.issuer !== grant.issuer) {
|
|
||||||
outcomes.push({
|
|
||||||
status: "failed",
|
|
||||||
detail:
|
|
||||||
metadata.issuer === 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 ${metadata.issuer}, 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,
|
|
||||||
fetchFn,
|
|
||||||
timeoutMs: remainingMs,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return aggregateOutcomes(outcomes);
|
|
||||||
} catch (err) {
|
|
||||||
// A store that cannot be read (a corrupt blob, a remote backend that 500s)
|
|
||||||
// is not a reason to abandon the clear the user asked for.
|
|
||||||
return {
|
|
||||||
status: "failed",
|
|
||||||
detail: err instanceof Error ? err.message : String(err),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
return aggregateOutcomes(outcomes);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ import { mcpAuth } from "../auth/mcpAuth.js";
|
||||||
import type { OAuthStorage } from "../auth/storage.js";
|
import type { OAuthStorage } from "../auth/storage.js";
|
||||||
import { parseOAuthState } from "../auth/utils.js";
|
import { parseOAuthState } from "../auth/utils.js";
|
||||||
import {
|
import {
|
||||||
revokeStoredOAuthTokens,
|
executeOAuthRevocation,
|
||||||
|
planOAuthRevocation,
|
||||||
type TokenRevocationOutcome,
|
type TokenRevocationOutcome,
|
||||||
} from "../auth/revocation.js";
|
} from "../auth/revocation.js";
|
||||||
import type { InspectorLogger } from "../logging/index.js";
|
import type { InspectorLogger } from "../logging/index.js";
|
||||||
|
|
@ -499,12 +500,15 @@ export class OAuthManager {
|
||||||
* Revoke the grant at the authorization server (RFC 7009), then drop the
|
* Revoke the grant at the authorization server (RFC 7009), then drop the
|
||||||
* local OAuth state.
|
* local OAuth state.
|
||||||
*
|
*
|
||||||
* Revocation runs **first** and reads what it needs out of the same store the
|
* The order is **snapshot → clear → revoke**, and it matters. Everything the
|
||||||
* next line wipes — the token, the client credentials and the discovered
|
* requests need is read out of the store first, because the clear empties it;
|
||||||
* `revocation_endpoint` all live there, so after the clear there is nothing
|
* but the clear then runs immediately rather than behind the network, because
|
||||||
* left to revoke with. It is best-effort by construction: every failure is
|
* a fresh authorization completing during a five-second revocation would
|
||||||
* reported through the returned outcome and the clear proceeds regardless,
|
* otherwise be deleted by a clear reasoning about the grant it replaced.
|
||||||
* because forgetting the tokens is what the caller actually asked for (#2144).
|
*
|
||||||
|
* Best-effort by construction: every failure is reported through the returned
|
||||||
|
* outcome and the clear has already happened regardless, because forgetting
|
||||||
|
* the tokens is what the caller actually asked for (#2144).
|
||||||
*
|
*
|
||||||
* `options.revoke === false` skips the request. That is not only an escape
|
* `options.revoke === false` skips the request. That is not only an escape
|
||||||
* hatch for an authorization server that mishandles it — disconnecting while
|
* hatch for an authorization server that mishandles it — disconnecting while
|
||||||
|
|
@ -519,18 +523,19 @@ export class OAuthManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverUrl = this.getServerUrl();
|
const serverUrl = this.getServerUrl();
|
||||||
const outcome = await revokeStoredOAuthTokens({
|
const plan = await planOAuthRevocation({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
storage: this.oauthConfig.storage,
|
storage: this.oauthConfig.storage,
|
||||||
fetchFn: this.params.effectiveAuthFetch,
|
|
||||||
enabled: options?.revoke,
|
enabled: options?.revoke,
|
||||||
logger: this.params.logger,
|
|
||||||
});
|
});
|
||||||
await this.oauthConfig.storage.clear(serverUrl);
|
await this.oauthConfig.storage.clear(serverUrl);
|
||||||
|
|
||||||
this.oauthFlowState = null;
|
this.oauthFlowState = null;
|
||||||
this.pendingAuthorizationScope = undefined;
|
this.pendingAuthorizationScope = undefined;
|
||||||
return outcome;
|
return executeOAuthRevocation(plan, {
|
||||||
|
fetchFn: this.params.effectiveAuthFetch,
|
||||||
|
logger: this.params.logger,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async isOAuthAuthorized(): Promise<boolean> {
|
async isOAuthAuthorized(): Promise<boolean> {
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,8 @@ Without it, clearing is silent from the authorization server's point of view: th
|
||||||
|
|
||||||
The request names the **refresh token** when there is one. RFC 7009 §2.1 asks an authorization server to also invalidate the access tokens issued under the same grant, so one request covers both halves; naming the access token instead would leave the long-lived one alive.
|
The request names the **refresh token** when there is one. RFC 7009 §2.1 asks an authorization server to also invalidate the access tokens issued under the same grant, so one request covers both halves; naming the access token instead would leave the long-lived one alive.
|
||||||
|
|
||||||
|
The order is **snapshot → clear → revoke**. Everything the request needs — the token, the client credentials, the discovered `revocation_endpoint` — is read out of the store first, because the clear empties it; but the clear then runs immediately rather than behind the network. Waiting would leave a window in which a *fresh* authorization could complete and then be deleted by a clear still reasoning about the grant it replaced.
|
||||||
|
|
||||||
> **It is best-effort, and the local clear always finishes.** An authorization server that advertises no `revocation_endpoint` is left behaving exactly as it did before this existed — nothing is sent. A network error, a non-2xx, or a slow server that trips the short timeout is reported (a toast in the web client, a status line in the TUI, a stderr warning from the CLI) and nothing more. Forgetting the tokens is what you asked for, so no failure on this leg stops it.
|
> **It is best-effort, and the local clear always finishes.** An authorization server that advertises no `revocation_endpoint` is left behaving exactly as it did before this existed — nothing is sent. A network error, a non-2xx, or a slow server that trips the short timeout is reported (a toast in the web client, a status line in the TUI, a stderr warning from the CLI) and nothing more. Forgetting the tokens is what you asked for, so no failure on this leg stops it.
|
||||||
|
|
||||||
> **Turning it off is a testing affordance, not only an escape hatch.** A client that walks away still holding live tokens is a case a server author may want to reproduce deliberately, to watch how the server under test copes with it.
|
> **Turning it off is a testing affordance, not only an escape hatch.** A client that walks away still holding live tokens is a case a server author may want to reproduce deliberately, to watch how the server under test copes with it.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue