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
4 changed files with 617 additions and 87 deletions

View file

@ -136,7 +136,10 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
}
return Promise.resolve(true);
});
clearOAuthTokens = vi.fn().mockResolvedValue(undefined);
// #2144: the clear path reads the returned RFC 7009 outcome.
clearOAuthTokens = vi
.fn()
.mockResolvedValue({ status: "skipped", reason: "no_endpoint" });
}
const instances: FakeInspectorClient[] = [];
return {

View file

@ -1326,8 +1326,18 @@ function App() {
const handleClearStoredOAuthFromSettings = useCallback(() => {
if (!settingsModalTarget) return;
void clearServerOAuthAndDisconnect(settingsModalTarget);
}, [settingsModalTarget, clearServerOAuthAndDisconnect]);
// Clear from *inside* the settings modal, so the draft is what the user is
// looking at: `settingsModalTarget` comes from the persisted `servers`
// list, and edits only reach it after the save debounce. Reading it would
// mean toggling "Revoke tokens on clear" and immediately clearing used the
// previous value — revoking despite an opt-out, or skipping despite an
// opt-in (#2144). The draft is the live answer; `settingsModalValue` falls
// back to the persisted entry whenever there is no draft.
void clearServerOAuthAndDisconnect({
...settingsModalTarget,
settings: settingsDraft ?? settingsModalTarget.settings,
});
}, [settingsModalTarget, settingsDraft, clearServerOAuthAndDisconnect]);
const onSettingsModalClose = useCallback(() => {
flushSettingsDraft();

View file

@ -9,6 +9,22 @@ import {
revokeToken,
selectRevocableToken,
} from "@inspector/core/auth/revocation.js";
import type { InspectorLogger } from "@inspector/core/logging/index.js";
/** A fully-typed `InspectorLogger` double, so the mock's shape is checked. */
function fakeLogger(): InspectorLogger {
return {
level: "info",
fatal: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
silent: vi.fn(),
child: vi.fn(() => fakeLogger()),
};
}
const SERVER_URL = "https://mcp.example.com/mcp";
const REVOKE_URL = "https://as.example.com/revoke";
@ -72,19 +88,47 @@ describe("revocationAuthMethods", () => {
).toEqual(["client_secret_post"]);
});
// RFC 8414 gives the revocation endpoint the token endpoint's default when it
// advertises no list of its own.
it("falls back to the token endpoint's list", () => {
// RFC 8414 §2 defaults an omitted revocation list to `client_secret_basic` —
// it does NOT inherit the token endpoint's. Inheriting would send POST
// credentials to an endpoint that never advertised that method.
it("ignores the token endpoint's list", () => {
expect(
revocationAuthMethods(
metadata({ token_endpoint_auth_methods_supported: ["none"] }),
metadata({
token_endpoint_auth_methods_supported: ["client_secret_post"],
}),
),
).toEqual(["none"]);
).toEqual([]);
});
it("yields nothing when neither is advertised", () => {
it("yields nothing when the revocation list is absent", () => {
expect(revocationAuthMethods(metadata())).toEqual([]);
});
// The empty list is not "no authentication": it is what makes the SDK apply
// RFC 8414's actual default.
it("an empty list resolves to the RFC 8414 default", () => {
const { init } = buildRevocationRequest({
endpoint: REVOKE_URL,
token: "r",
tokenTypeHint: "refresh_token",
clientInformation: { client_id: "cid", client_secret: "sec" },
supportedAuthMethods: revocationAuthMethods(metadata()),
});
expect(headerOf(init, "Authorization")).toBe(`Basic ${btoa("cid:sec")}`);
});
it("an empty list leaves a public client unauthenticated", () => {
const { init } = buildRevocationRequest({
endpoint: REVOKE_URL,
token: "r",
tokenTypeHint: "refresh_token",
clientInformation: { client_id: "cid" },
supportedAuthMethods: revocationAuthMethods(metadata()),
});
expect(headerOf(init, "Authorization")).toBeUndefined();
expect(body(init).get("client_id")).toBe("cid");
});
});
describe("buildRevocationRequest", () => {
@ -203,6 +247,26 @@ describe("revokeToken", () => {
});
});
// The web fetch is `createRemoteFetch`, which re-issues the call as a POST to
// `/api/fetch` and drops `init.signal`; the backend's outbound fetch gets no
// signal either. A signal-only bound is therefore inert on exactly the path
// the timeout exists for, so the deadline has to hold against a fetch that
// ignores the signal entirely.
it("gives up on a fetch that never settles and ignores the signal", async () => {
const outcome = await revokeToken({
endpoint: REVOKE_URL,
token: "r",
tokenTypeHint: "refresh_token",
supportedAuthMethods: [],
fetchFn: () => new Promise<Response>(() => {}),
timeoutMs: 20,
});
expect(outcome).toMatchObject({ status: "failed", endpoint: REVOKE_URL });
expect(outcome.status === "failed" ? outcome.detail : "").toContain(
"timed out",
);
});
// The teardown is already committed by the time this runs, so a wedged
// authorization server must not be able to hold it open.
it("bounds the request with an abort signal", async () => {
@ -334,18 +398,7 @@ describe("revokeStoredOAuthTokens", () => {
it("warns and reports rather than throwing when the request fails", async () => {
await seed();
const warn = vi.fn();
const logger = {
level: "info",
fatal: vi.fn(),
error: vi.fn(),
warn,
info: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
silent: vi.fn(),
child: vi.fn(),
};
const logger = fakeLogger();
const fetchFn = vi.fn<typeof fetch>(
async () => new Response(null, { status: 500, statusText: "Boom" }),
);
@ -354,52 +407,38 @@ describe("revokeStoredOAuthTokens", () => {
serverUrl: SERVER_URL,
storage,
fetchFn,
logger: logger as unknown as Parameters<
typeof revokeStoredOAuthTokens
>[0]["logger"],
logger,
});
expect(outcome.status).toBe("failed");
expect(warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledTimes(1);
});
it("logs the no-endpoint case at debug", async () => {
await seed({ revocation_endpoint: undefined });
const debug = vi.fn();
const logger = {
level: "info",
fatal: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug,
trace: vi.fn(),
silent: vi.fn(),
child: vi.fn(),
};
const logger = fakeLogger();
await revokeStoredOAuthTokens({
serverUrl: SERVER_URL,
storage,
fetchFn: vi.fn<typeof fetch>(),
logger: logger as unknown as Parameters<
typeof revokeStoredOAuthTokens
>[0]["logger"],
logger,
});
expect(debug).toHaveBeenCalledTimes(1);
expect(logger.debug).toHaveBeenCalledTimes(1);
});
// A store that cannot be read is not a reason to abandon the clear the user
// asked for, so the read is inside the try.
// asked for, so the read is inside the try. Spying on the real instance keeps
// the `OAuthStorage` contract intact — a spread-and-cast stand-in would type
// as storage while being a plain object with none of its methods.
it("reports a store read failure as failed", async () => {
const broken = {
...storage,
getTokens: () => Promise.reject(new Error("store unreadable")),
} as unknown as BrowserOAuthStorage;
vi.spyOn(storage, "getTokens").mockRejectedValue(
new Error("store unreadable"),
);
await expect(
revokeStoredOAuthTokens({
serverUrl: SERVER_URL,
storage: broken,
storage,
fetchFn: vi.fn<typeof fetch>(),
}),
).resolves.toEqual({ status: "failed", detail: "store unreadable" });

View file

@ -138,6 +138,31 @@ describe("OAuth token revocation (RFC 7009)", () => {
return storage;
}
// The fixture enforces RFC 7009 §2.1 client authentication, so this is what
// makes the passing cases below mean something: a request with the wrong
// credentials is refused, and the endpoint is not simply answering 200 to
// anything.
it("refuses a revocation request with the wrong client secret", async () => {
const response = await fetch(`${serverUrl}/oauth/revoke`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:wrong`).toString("base64")}`,
},
body: new URLSearchParams({ token: "anything" }),
});
expect(response.status).toBe(401);
});
it("refuses a revocation request naming no client at all", async () => {
const response = await fetch(`${serverUrl}/oauth/revoke`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token: "anything" }),
});
expect(response.status).toBe(401);
});
it("advertises a revocation endpoint", () => {
expect(metadata.revocation_endpoint).toBe(`${serverUrl}/oauth/revoke`);
});