feat(auth): revoke OAuth tokens at the authorization server on clear (#2144)

Clearing the Inspector's OAuth state deleted the local copy of the tokens
and stopped there. The access token — and the refresh token, which is
long-lived by design — stayed valid at the authorization server until they
expired on their own, so a day of connect/disconnect iteration left it
holding grants for sessions that ended hours ago. RFC 7009 §1 describes
exactly that case, and nothing in the repo constructed a revocation
request, so a server author implementing `revocation_endpoint` had no way
to exercise it with the tool that exists to exercise this.

core/auth/revocation.ts builds and sends the request; the three clear paths
call it, so the behavior is written once — web's "Clear OAuth state and
disconnect" (both the active and stored-only paths), the TUI's Auth tab,
and the CLI's --relogin. It runs BEFORE the local clear, because the token,
the client credentials and the discovered `revocation_endpoint` all live in
the store the clear is about to empty. The request names the refresh token
when there is one: §2.1 asks the AS to also invalidate the access tokens
issued under the same grant, so one request covers both halves.

Best-effort by construction. Every path returns a TokenRevocationOutcome
rather than throwing: no advertised endpoint means nothing is sent and that
authorization server behaves exactly as before, and a network error, a
non-2xx, a 5s timeout or an unreadable store is reported and nothing more.
Forgetting the tokens is what the user asked for. `lost_authorization_state`
recovery deliberately skips it — it clears a half-finished flow to retry it,
and an authorization that never completed has no grant to revoke.

Client authentication resolves the preregistered slot first, mirroring
BaseOAuthClientProvider.clientInformation; reading only the dynamic slot
would send no authentication for a server configured with `oauth.clientId`.
The Basic credential is byte-identical to the SDK's applyBasicAuth so this
request and the token request cannot present the same secret differently.

Opt out per server with `oauth.revokeOnClear` (default on, only `false`
written to disk) or per CLI run with --no-revoke. Read at clear time rather
than connect time, so toggling needs no reconnect. Turning it off is a
testing affordance: a client that walks away still holding live tokens is a
case a server author may want to reproduce.

test-servers now advertises `revocation_endpoint` and serves POST
/oauth/revoke, faithful on §2.2 (an unknown token is a success) and §2.1
(revoking a refresh token invalidates the access tokens under the same
grant), with oauth-revocation-http.json / oauth-no-revocation-http.json as
showcase configs.

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 00:48:57 -04:00
commit 65fd2b9e35

View file

@ -21,6 +21,7 @@ import { EMPTY_SETTINGS } from "../utils/serverSettingsDefaults";
import { useSessionRef } from "./useSessionRef";
import { useTabUiState } from "./useTabUiState";
import {
revocationSuffix,
useOAuthRecovery,
type FetchRequestSource,
type OAuthRecovery,
@ -58,6 +59,14 @@ vi.mock("@inspector/core/mcp/remote/index.js", () => ({
RemoteInspectorClientStorage: class {
saveSession = saveSessionMock;
},
// #2144: `clearServerOAuthAndDisconnect` builds the backend-proxied fetch the
// revocation POST would travel on. Nothing here exercises a real request, so
// this only has to exist.
createRemoteFetch: () => remoteFetchMock,
}));
const { remoteFetchMock } = vi.hoisted(() => ({
remoteFetchMock: vi.fn<typeof fetch>(async () => new Response(null)),
}));
vi.mock("../lib/authToken", () => ({ getAuthToken: () => "test-token" }));
@ -262,7 +271,7 @@ beforeEach(() => {
window.sessionStorage.clear();
window.history.replaceState({}, "", "/");
oauthStorageMock.load.mockResolvedValue(undefined);
clearServerOAuthStateMock.mockResolvedValue(true);
clearServerOAuthStateMock.mockResolvedValue({ cleared: true });
saveSessionMock.mockResolvedValue(undefined);
});
@ -1271,9 +1280,37 @@ describe("useOAuthRecovery", () => {
});
});
// #2144 — only the two outcomes a user can act on are surfaced. A skip
// describes the status quo, and reporting it would turn a confirmation into a
// notice about something that did not need to happen.
describe("revocationSuffix", () => {
it("announces a successful revocation", () => {
expect(
revocationSuffix({
status: "revoked",
tokenTypeHint: "refresh_token",
endpoint: "https://as.example/revoke",
}),
).toContain("revoked at the authorization server");
});
it("warns that the grant may still be live after a failure", () => {
const text = revocationSuffix({ status: "failed", detail: "boom" });
expect(text).toContain("boom");
expect(text).toContain("may still be valid");
});
it("says nothing for a skip or an absent outcome", () => {
expect(
revocationSuffix({ status: "skipped", reason: "no_endpoint" }),
).toBe("");
expect(revocationSuffix(undefined)).toBe("");
});
});
describe("clearing stored OAuth state", () => {
it("says nothing when there was nothing to clear", async () => {
clearServerOAuthStateMock.mockResolvedValue(false);
clearServerOAuthStateMock.mockResolvedValue({ cleared: false });
const h = harness({ servers: [entry("a")], activeServerId: "a" });
await act(async () => {
await h.api().clearServerOAuthAndDisconnect(entry("a"));