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
4 changed files with 2159 additions and 46 deletions

View file

@ -1,40 +1,47 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { clearServerOAuthState } from "./clearServerOAuthState";
const SERVER_URL = "https://mcp.example.com/mcp";
function skipped(): TokenRevocationOutcome {
return { status: "skipped", reason: "no_endpoint" };
}
describe("clearServerOAuthState", () => {
let storage: BrowserOAuthStorage;
beforeEach(async () => {
storage = new BrowserOAuthStorage();
await storage.clear("https://mcp.example.com/mcp");
await storage.clear(SERVER_URL);
});
it("clears storage by server URL when not the active connection", async () => {
await storage.saveTokens("https://mcp.example.com/mcp", {
await storage.saveTokens(SERVER_URL, {
access_token: "tok",
token_type: "Bearer",
});
const cleared = await clearServerOAuthState({
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
const { cleared } = await clearServerOAuthState({
config: { type: "streamable-http", url: SERVER_URL },
isActiveConnection: false,
oauthStorage: storage,
});
expect(cleared).toBe(true);
expect(
await storage.getTokens("https://mcp.example.com/mcp"),
).toBeUndefined();
expect(await storage.getTokens(SERVER_URL)).toBeUndefined();
});
it("uses the live client when clearing the active connection", async () => {
const clearOAuthTokens = vi.fn<InspectorClient["clearOAuthTokens"]>();
const clearOAuthTokens = vi.fn<InspectorClient["clearOAuthTokens"]>(
async () => skipped(),
);
const inspectorClient = { clearOAuthTokens };
const cleared = await clearServerOAuthState({
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
const { cleared } = await clearServerOAuthState({
config: { type: "streamable-http", url: SERVER_URL },
inspectorClient,
isActiveConnection: true,
oauthStorage: storage,
@ -42,15 +49,108 @@ describe("clearServerOAuthState", () => {
expect(cleared).toBe(true);
expect(clearOAuthTokens).toHaveBeenCalledTimes(1);
expect(clearOAuthTokens).toHaveBeenCalledWith({ revoke: true });
});
it("returns false for stdio servers", async () => {
it("returns cleared: false for stdio servers", async () => {
await expect(
clearServerOAuthState({
config: { type: "stdio", command: "node", args: [] },
isActiveConnection: false,
oauthStorage: storage,
}),
).resolves.toBe(false);
).resolves.toEqual({ cleared: false });
});
// #2144 — the opt-out has to reach the live client, since that is where the
// RFC 7009 request is actually made.
it("forwards revoke: false to the live client", async () => {
const clearOAuthTokens = vi.fn<InspectorClient["clearOAuthTokens"]>(
async () => skipped(),
);
await clearServerOAuthState({
config: { type: "streamable-http", url: SERVER_URL },
inspectorClient: { clearOAuthTokens },
isActiveConnection: true,
oauthStorage: storage,
revoke: false,
});
expect(clearOAuthTokens).toHaveBeenCalledWith({ revoke: false });
});
// #2144 — the non-active path revokes from the store directly. The whole
// point is that it happens *before* the clear: after it there is no token,
// no client id and no cached metadata left to build a request from.
it("revokes before clearing when this server is not the active connection", async () => {
await storage.saveTokens(SERVER_URL, {
access_token: "tok",
token_type: "Bearer",
refresh_token: "refresh-tok",
});
await storage.saveServerMetadata(SERVER_URL, {
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"],
});
let tokensAtRequestTime: unknown;
const fetchFn = vi.fn<typeof fetch>(async () => {
tokensAtRequestTime = await storage.getTokens(SERVER_URL);
return new Response(null, { status: 200 });
});
const { revocation } = await clearServerOAuthState({
config: { type: "streamable-http", url: SERVER_URL },
isActiveConnection: false,
oauthStorage: storage,
fetchFn,
});
expect(revocation).toEqual({
status: "revoked",
tokenTypeHint: "refresh_token",
endpoint: "https://as.example.com/revoke",
});
expect(tokensAtRequestTime).toBeDefined();
expect(await storage.getTokens(SERVER_URL)).toBeUndefined();
});
// Without a backend-proxied fetch the request would go out on the page
// origin, where a real authorization server's missing CORS headers reject it.
// Skipping is honest; attempting it would fail loudly on real deployments and
// succeed on permissive ones.
it("skips revocation on the non-active path when given no fetch", async () => {
await storage.saveTokens(SERVER_URL, {
access_token: "tok",
token_type: "Bearer",
});
const { revocation } = await clearServerOAuthState({
config: { type: "streamable-http", url: SERVER_URL },
isActiveConnection: false,
oauthStorage: storage,
});
expect(revocation).toEqual({ status: "skipped", reason: "disabled" });
expect(await storage.getTokens(SERVER_URL)).toBeUndefined();
});
it("skips the request on the non-active path when revoke is off", async () => {
const fetchFn = vi.fn<typeof fetch>();
const { revocation } = await clearServerOAuthState({
config: { type: "streamable-http", url: SERVER_URL },
isActiveConnection: false,
oauthStorage: storage,
revoke: false,
fetchFn,
});
expect(fetchFn).not.toHaveBeenCalled();
expect(revocation).toEqual({ status: "skipped", reason: "disabled" });
});
});

View file

@ -1,3 +1,7 @@
import {
revokeStoredOAuthTokens,
type TokenRevocationOutcome,
} from "@inspector/core/auth/revocation.js";
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
import { getOAuthServerUrl } from "@inspector/core/mcp/config.js";
import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
@ -10,25 +14,71 @@ export interface ClearServerOAuthStateParams {
isActiveConnection: boolean;
/** Shared web OAuth store; required so clear hits the same blob as connect. */
oauthStorage: OAuthStorage;
/**
* Whether to revoke the grant at the authorization server first (RFC 7009,
* #2144). Defaults to on. Two callers turn it off: a server whose settings
* opted out, and `lost_authorization_state` recovery — that path clears a
* half-finished flow in order to retry it, so there is no completed grant to
* revoke and the request would be noise at best.
*/
revoke?: boolean;
/**
* Fetch used for the revocation POST when this server is **not** the active
* connection (there is no live client to borrow one from). In the browser
* this must be the backend-proxied fetch the OAuth flow itself uses —
* `globalThis.fetch` would put the request on the page's origin, where an
* authorization server that serves no CORS headers rejects it. Omitting it
* skips revocation on that path rather than sending a request that cannot
* work.
*/
fetchFn?: typeof fetch;
}
export interface ClearServerOAuthStateResult {
/** False when the config has no OAuth server URL — nothing was cleared. */
cleared: boolean;
/** What the RFC 7009 leg did. Absent when nothing was cleared. */
revocation?: TokenRevocationOutcome;
}
/**
* Clear persisted OAuth state (tokens, DCR/CIMD client id, PKCE, etc.) for an
* HTTP MCP server. When clearing the active connection, uses the live client so
* in-memory flow state is reset too.
* HTTP MCP server, revoking the grant at the authorization server first. When
* clearing the active connection, uses the live client so in-memory flow state
* is reset too.
*
* Revocation is best-effort throughout — an authorization server advertising no
* `revocation_endpoint` is untouched, and a failure is reported in the result
* rather than thrown — so the local clear always finishes.
*/
export async function clearServerOAuthState(
params: ClearServerOAuthStateParams,
): Promise<boolean> {
): Promise<ClearServerOAuthStateResult> {
const serverUrl = getOAuthServerUrl(params.config);
if (!serverUrl) {
return false;
return { cleared: false };
}
const revoke = params.revoke !== false;
if (params.isActiveConnection && params.inspectorClient) {
await params.inspectorClient.clearOAuthTokens();
} else {
await params.oauthStorage.clear(serverUrl);
const revocation = await params.inspectorClient.clearOAuthTokens({
revoke,
});
return { cleared: true, revocation };
}
return true;
// 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.
const fetchFn = params.fetchFn;
const revocation: TokenRevocationOutcome =
revoke && fetchFn
? await revokeStoredOAuthTokens({
serverUrl,
storage: params.oauthStorage,
fetchFn,
})
: { status: "skipped", reason: "disabled" };
await params.oauthStorage.clear(serverUrl);
return { cleared: true, revocation };
}

View file

@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
const { createRemoteFetchMock } = vi.hoisted(() => ({
createRemoteFetchMock: vi.fn(),
}));
vi.mock("@inspector/core/mcp/remote/index.js", () => ({
createRemoteFetch: createRemoteFetchMock,
}));
import {
getWebProxiedFetch,
resetWebProxiedFetchCacheForTests,
} from "./webProxiedFetch";
describe("getWebProxiedFetch", () => {
beforeEach(() => {
resetWebProxiedFetchCacheForTests();
createRemoteFetchMock.mockReset();
createRemoteFetchMock.mockImplementation(() => vi.fn());
});
it("builds a remote fetch against the page origin and the API token", () => {
getWebProxiedFetch("tok");
expect(createRemoteFetchMock).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: `${window.location.protocol}//${window.location.host}`,
authToken: "tok",
}),
);
});
it("reuses the instance for the same origin and token", () => {
const first = getWebProxiedFetch("tok");
expect(getWebProxiedFetch("tok")).toBe(first);
expect(createRemoteFetchMock).toHaveBeenCalledTimes(1);
});
it("rebuilds when the token changes", () => {
const first = getWebProxiedFetch("tok");
expect(getWebProxiedFetch("other")).not.toBe(first);
expect(createRemoteFetchMock).toHaveBeenCalledTimes(2);
});
it("forwards a fetch that reaches the injected base fetch", async () => {
const inner = vi.fn<typeof fetch>(async () => new Response(null));
createRemoteFetchMock.mockImplementation(
({ fetchFn }: { fetchFn: typeof fetch }) => fetchFn,
);
const proxied = getWebProxiedFetch();
const globalFetch = vi
.spyOn(globalThis, "fetch")
.mockImplementation(inner as typeof fetch);
await proxied("https://example.com");
expect(inner).toHaveBeenCalled();
globalFetch.mockRestore();
});
});

View file

@ -0,0 +1,44 @@
import { createRemoteFetch } from "@inspector/core/mcp/remote/index.js";
let cached: { cacheKey: string; fetchFn: typeof fetch } | undefined;
const defaultFetch: typeof fetch = (...args) => globalThis.fetch(...args);
/**
* The backend-proxied fetch the browser must use for any request aimed at an
* authorization server rather than at the Inspector itself.
*
* `createWebEnvironment` hands the same thing to `InspectorClient`, so an OAuth
* request made through a live client already travels this way. This exists for
* the paths that have no client to borrow one from — clearing (and revoking)
* the stored OAuth state of a server that is not the active connection.
*
* Going direct is not an option there: an authorization server serves no CORS
* headers for a page origin it has never heard of, so `globalThis.fetch` would
* fail on nearly every real deployment while working on a permissive one — the
* worst shape of bug to carry.
*
* Cached as a cache of one, keyed the same way `getWebRemoteOAuthStorage` keys
* its store: the web app has a stable origin and a page-lifetime API token, so
* the key does not change within a session.
*/
export function getWebProxiedFetch(authToken?: string): typeof fetch {
if (typeof window === "undefined") {
throw new Error("getWebProxiedFetch requires a browser environment");
}
const baseUrl = `${window.location.protocol}//${window.location.host}`;
const cacheKey = `${baseUrl}\0${authToken ?? ""}`;
if (cached?.cacheKey === cacheKey) {
return cached.fetchFn;
}
cached = {
cacheKey,
fetchFn: createRemoteFetch({ baseUrl, authToken, fetchFn: defaultFetch }),
};
return cached.fetchFn;
}
/** @internal Vitest isolation */
export function resetWebProxiedFetchCacheForTests(): void {
cached = undefined;
}