fix(auth): address Copilot review round 1 on the RFC 7009 revocation (#2144)

- RFC 8414 §2 defaults an omitted `revocation_endpoint_auth_methods_supported`
  to `client_secret_basic`; it does not inherit the token endpoint's list.
  Inheriting made metadata advertising only `client_secret_post` there send
  POST credentials to an endpoint that never advertised the method.
- The 5s timeout was inert on the web paths: `createRemoteFetch` re-issues the
  call as a POST to `/api/fetch` and drops `init.signal`, and the backend's
  outbound fetch gets none either, so a wedged AS could hold the teardown open
  indefinitely. A wall-clock race now enforces the deadline; the signal is kept
  because it genuinely cancels the direct-fetch paths.
- The settings-modal Clear read `settingsModalTarget`, which comes from the
  persisted `servers` list, while edits live in `settingsDraft` until the save
  debounce. Toggling the checkbox and clearing immediately used the previous
  value. It now merges the draft.
- `--relogin` clears both key spellings, so it now revokes from both rather
  than stopping at the first populated one — a stale entry under the other
  spelling is a live grant. The normalised key goes first, matching
  `findStoredServerState`, and a second key holding the same token is skipped.
- The fixture's /oauth/revoke now ENFORCES RFC 7009 §2.1 client
  authentication (both RFC 6749 §2.3.1 forms), so the e2e's claim that the
  Inspector authenticates correctly is actually tested; two negative cases
  assert a wrong secret and an unidentified client are refused.
- Dropped the `as unknown as` casts from the tests: a typed `InspectorLogger`
  double, and a spy on the real storage instead of a spread-and-cast.
- Added CLI-level tests through `runCli` for `--no-revoke`, the per-server
  `oauth.revokeOnClear`, and the stderr warning, and TUI tests for the
  forwarded opt-out and the rendered failure.

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:
cliffhall 2026-08-28 01:30:09 -04:00
commit 7f27d7e9c7
3 changed files with 617 additions and 87 deletions

View file

@ -128,11 +128,10 @@ describe("clearStoredAuthForRelogin", () => {
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
});
// Both key spellings are cleared, but they are two spellings of one server:
// a second request would name a grant the first one already ended. The raw
// key here holds nothing, so this also proves the walk does not stop at the
// first empty one.
it("sends one request even though both key spellings are cleared", async () => {
// Both spellings resolve to the same stored entry, so this is one grant and
// must produce one request. It also proves the walk does not stop at the
// first empty key: the raw spelling holds nothing.
it("sends one request when both key spellings name the same grant", async () => {
seed();
const fetchSpy = vi
.spyOn(globalThis, "fetch")
@ -148,6 +147,75 @@ describe("clearStoredAuthForRelogin", () => {
}
});
// Both keys are deleted, so both must be revoked: a stale entry under the
// other spelling is still a live grant at the authorization server, and
// dropping it locally without revoking is the exact leak this closes.
// The *reported* outcome is the normalised key's, matching the precedence
// `findStoredServerState` reads with — that is the grant a connect uses.
it("revokes a stale entry under the other spelling too", async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-both-"));
const file = path.join(dir, "oauth.json");
const metadata = {
issuer: "https://as.example.com",
authorization_endpoint: "https://as.example.com/authorize",
token_endpoint: "https://as.example.com/token",
revocation_endpoint: "https://as.example.com/revoke",
response_types_supported: ["code"],
};
fs.writeFileSync(
file,
JSON.stringify({
servers: {
// The raw spelling the transport keyed by, holding a STALE grant.
"https://example.com": {
tokens: {
access_token: "stale-a",
token_type: "Bearer",
refresh_token: "stale-r",
},
serverMetadata: metadata,
},
// The normalised spelling, holding the CURRENT grant.
"https://example.com/": {
tokens: {
access_token: "live-a",
token_type: "Bearer",
refresh_token: "live-r",
},
serverMetadata: metadata,
},
},
idpSessions: {},
}),
"utf8",
);
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
resetNodeOAuthStorageCache();
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(null, { status: 200 }));
try {
await expect(
clearStoredAuthForRelogin("https://example.com"),
).resolves.toMatchObject({ status: "revoked" });
const tokens = fetchSpy.mock.calls.map((call) =>
new URLSearchParams(String(call[1]?.body)).get("token"),
);
// Normalised first, then the stale raw one — neither is left behind.
expect(tokens).toEqual(["live-r", "stale-r"]);
} finally {
fetchSpy.mockRestore();
}
const blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
servers: Record<string, unknown>;
};
expect(blob.servers["https://example.com"]).toBeUndefined();
expect(blob.servers["https://example.com/"]).toBeUndefined();
});
it("deletes the local entry even when the request fails", async () => {
const file = seed();
const fetchSpy = vi

View file

@ -0,0 +1,205 @@
/**
* `--relogin` token revocation, driven through `runCli` (#2144).
*
* The helper's own tests cover `clearStoredAuthForRelogin({ revoke })`. What
* they cannot cover is the wiring above it: that Commander maps `--no-revoke`,
* that the per-server `oauth.revokeOnClear` reaches the call, and that a failed
* revocation is reported without changing what `--relogin` does. A regression
* in any of those turns revocation silently back on or off, which is precisely
* the class of bug the opt-out exists to make controllable.
*
* Every case here fails to connect on purpose — the fetch double refuses
* everything but the revocation endpoint. That is fine: revocation runs before
* the connect, so the assertions are about the requests made and the warnings
* printed, not about the exit code.
*/
import { describe, it, expect, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { resetNodeOAuthStorageCache } from "@inspector/core/auth/node/storage-node.js";
import { runCli } from "./helpers/cli-runner.js";
const SERVER_URL = "https://example.com/mcp";
const REVOKE_URL = "https://as.example.com/revoke";
let dir: string | undefined;
let prevPath: string | undefined;
afterEach(() => {
if (prevPath === undefined) delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = prevPath;
prevPath = undefined;
resetNodeOAuthStorageCache();
vi.restoreAllMocks();
if (dir) {
fs.rmSync(dir, { recursive: true, force: true });
dir = undefined;
}
});
/** Seed an OAuth store holding a revocable grant for {@link SERVER_URL}. */
function seedStore(): void {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-revoke-e2e-"));
const file = path.join(dir, "oauth.json");
fs.writeFileSync(
file,
JSON.stringify({
servers: {
[SERVER_URL]: {
tokens: {
access_token: "a",
token_type: "Bearer",
refresh_token: "r",
},
serverMetadata: {
issuer: "https://as.example.com",
authorization_endpoint: "https://as.example.com/authorize",
token_endpoint: "https://as.example.com/token",
revocation_endpoint: REVOKE_URL,
response_types_supported: ["code"],
},
},
},
idpSessions: {},
}),
"utf8",
);
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
resetNodeOAuthStorageCache();
}
/**
* Answer the revocation endpoint with `status` and refuse everything else, so
* the run stops at the connect instead of reaching the network.
*/
function stubFetch(status = 200): ReturnType<typeof vi.spyOn> {
return vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
if (String(input instanceof Request ? input.url : input) === REVOKE_URL) {
return Promise.resolve(new Response(null, { status }));
}
return Promise.reject(new Error("network blocked in test"));
}) as ReturnType<typeof vi.spyOn>;
}
function revocationCalls(spy: { mock: { calls: unknown[][] } }): unknown[][] {
return spy.mock.calls.filter(
(call) => String(call[0] as string) === REVOKE_URL,
);
}
/** A catalog holding one HTTP server, optionally opting out of revocation. */
function writeCatalog(revokeOnClear?: boolean): string {
const catalogDir = fs.mkdtempSync(
path.join(os.tmpdir(), "cli-relogin-catalog-"),
);
const catalogPath = path.join(catalogDir, "mcp.json");
fs.writeFileSync(
catalogPath,
JSON.stringify({
mcpServers: {
remote: {
type: "streamable-http",
url: SERVER_URL,
...(revokeOnClear !== undefined && { oauth: { revokeOnClear } }),
},
},
}),
"utf8",
);
return catalogPath;
}
describe("--relogin token revocation", () => {
it("revokes the stored grant by default", async () => {
seedStore();
const fetchSpy = stubFetch();
await runCli([
"--relogin",
"--server-url",
SERVER_URL,
"--method",
"tools/list",
]);
expect(revocationCalls(fetchSpy)).toHaveLength(1);
});
// Commander turns `--no-revoke` into `options.revoke === false`; nothing else
// proves that mapping survives a refactor of the flag.
it("--no-revoke skips the request", async () => {
seedStore();
const fetchSpy = stubFetch();
await runCli([
"--relogin",
"--no-revoke",
"--server-url",
SERVER_URL,
"--method",
"tools/list",
]);
expect(revocationCalls(fetchSpy)).toHaveLength(0);
});
it("honors the per-server oauth.revokeOnClear opt-out", async () => {
seedStore();
const catalogPath = writeCatalog(false);
const fetchSpy = stubFetch();
try {
await runCli([
"--catalog",
catalogPath,
"--server",
"remote",
"--relogin",
"--method",
"tools/list",
]);
expect(revocationCalls(fetchSpy)).toHaveLength(0);
} finally {
fs.rmSync(path.dirname(catalogPath), { recursive: true, force: true });
}
});
it("revokes for a catalog server that did not opt out", async () => {
seedStore();
const catalogPath = writeCatalog();
const fetchSpy = stubFetch();
try {
await runCli([
"--catalog",
catalogPath,
"--server",
"remote",
"--relogin",
"--method",
"tools/list",
]);
expect(revocationCalls(fetchSpy)).toHaveLength(1);
} finally {
fs.rmSync(path.dirname(catalogPath), { recursive: true, force: true });
}
});
// A failed revocation must be visible — the grant is still live at the
// authorization server — without turning `--relogin` into a failure, which
// is a local delete the user still gets.
it("warns on stderr when the authorization server refuses", async () => {
seedStore();
stubFetch(500);
const result = await runCli([
"--relogin",
"--server-url",
SERVER_URL,
"--method",
"tools/list",
]);
expect(result.stderr).toMatch(/could not revoke the OAuth grant/i);
});
});

View file

@ -4,6 +4,7 @@ import {
} from "@inspector/core/auth/node/storage-node.js";
import {
revokeStoredOAuthTokens,
selectRevocableToken,
type TokenRevocationOutcome,
} from "@inspector/core/auth/revocation.js";
import { createProxyFetch } from "@inspector/core/mcp/node/proxyFetch.js";
@ -40,13 +41,17 @@ export async function clearStoredAuthForRelogin(
// this is about to empty. Best-effort — the outcome is returned for the
// caller to report, never thrown, so `--relogin` succeeds regardless.
//
// Only the key that actually holds the state is revoked from: the two keys
// below are two spellings of one server, so revoking from both would send a
// second request for a grant the first one already ended.
// Both spellings are cleared below, so both are revoked from — a stale entry
// under the other key is a live grant at the authorization server, and
// deleting it locally without revoking is exactly the leak this closes. The
// normalised key goes first because that is the precedence `findStoredServerState`
// reads with, so the grant actually in use is the one whose outcome is
// reported; a second key holding the *same* token is skipped rather than
// revoked twice.
const revocation =
options?.revoke === false
? undefined
: await revokeFirstStoredKey(storage, [raw, normalized]);
: await revokeStoredKeys(storage, [normalized, raw]);
await storage.clear(raw);
if (normalized !== raw) {
await storage.clear(normalized);
@ -58,27 +63,43 @@ export async function clearStoredAuthForRelogin(
}
/**
* Revoke against the first of `keys` that actually has a revocable token,
* returning that attempt's outcome. Reports the last "nothing to do" answer
* when no key holds one, so the caller can still distinguish "no tokens" from
* "this authorization server advertises no revocation endpoint".
* Revoke every distinct grant held under `keys`, in order, and report the first
* key that had something to revoke.
*
* Both keys are about to be deleted, so both are revoked from: a stale entry
* under the other spelling is still a live grant at the authorization server,
* and deleting it locally without revoking is the leak this whole change
* closes. Two keys holding the *same* token are one grant, so the duplicate is
* skipped rather than producing a second request for something already ended.
*
* The reported outcome is the first key's that was not "nothing to do", which
* with `keys` in `findStoredServerState` precedence means the grant the CLI
* would actually have connected with. When no key holds a token, the last
* "nothing to do" answer is returned so the caller can still tell "no tokens"
* from "this authorization server advertises no revocation endpoint".
*/
async function revokeFirstStoredKey(
async function revokeStoredKeys(
storage: NodeOAuthStorage,
keys: string[],
): Promise<TokenRevocationOutcome | undefined> {
const fetchFn = createProxyFetch() ?? fetch;
let last: TokenRevocationOutcome | undefined;
const revokedTokens = new Set<string>();
let reported: TokenRevocationOutcome | undefined;
let lastSkip: TokenRevocationOutcome | undefined;
for (const key of new Set(keys)) {
const token = selectRevocableToken(await storage.getTokens(key))?.token;
if (token !== undefined && revokedTokens.has(token)) continue;
const outcome = await revokeStoredOAuthTokens({
serverUrl: key,
storage,
fetchFn,
});
if (!(outcome.status === "skipped" && outcome.reason === "no_tokens")) {
return outcome;
if (outcome.status === "skipped" && outcome.reason === "no_tokens") {
lastSkip = outcome;
continue;
}
last = outcome;
if (token !== undefined) revokedTokens.add(token);
reported ??= outcome;
}
return last;
return reported ?? lastSkip;
}