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

View file

@ -707,6 +707,7 @@ describe("ServerSettingsForm", () => {
tokenUrl: "",
enterpriseManaged: false,
requestRefreshToken: true,
revokeOnClear: true,
});
});
@ -751,6 +752,47 @@ describe("ServerSettingsForm", () => {
);
});
// #2144 — the RFC 7009 opt-out. On by default: leaving it off silently is
// what leaves the authorization server holding a live grant.
it("renders Revoke tokens on clear checked by default", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
expect(screen.getByLabelText("Revoke tokens on clear")).toBeChecked();
});
it("renders Revoke tokens on clear unchecked when the server opted out", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{ ...emptySettings, oauthRevokeOnClear: false }}
expandedSections={["oauth"]}
/>,
);
expect(screen.getByLabelText("Revoke tokens on clear")).not.toBeChecked();
});
it("toggles Revoke tokens on clear through onOAuthChange", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onOAuthChange={onOAuthChange}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
await user.click(screen.getByLabelText("Revoke tokens on clear"));
expect(onOAuthChange).toHaveBeenLastCalledWith(
expect.objectContaining({ revokeOnClear: false }),
);
});
it("warns when opting out while offline_access is still in Scopes", () => {
renderWithMantine(
<ServerSettingsForm
@ -1255,6 +1297,7 @@ describe("ServerSettingsForm", () => {
tokenUrl: "",
enterpriseManaged: true,
requestRefreshToken: true,
revokeOnClear: true,
});
});
@ -1363,6 +1406,7 @@ describe("ServerSettingsForm", () => {
tokenUrl: "",
enterpriseManaged: false,
requestRefreshToken: true,
revokeOnClear: true,
});
});

View file

@ -521,6 +521,8 @@ export function ServerSettingsForm({
onInsufficientScope: settings.oauthOnInsufficientScope,
// Unset means the default, on — only an explicit opt-out is stored.
requestRefreshToken: settings.oauthRequestRefreshToken ?? true,
// Unset means the default, on — only an explicit opt-out is stored.
revokeOnClear: settings.oauthRevokeOnClear ?? true,
};
}
@ -570,6 +572,15 @@ export function ServerSettingsForm({
});
}
function handleRevokeOnClearChange(
event: ChangeEvent<HTMLInputElement>,
): void {
onOAuthChange({
...currentOAuth(),
revokeOnClear: event.currentTarget.checked,
});
}
function handleAddAuthorizationParam(): void {
changeAuthorizationParams([...authorizationParams, { key: "", value: "" }]);
}
@ -917,6 +928,12 @@ export function ServerSettingsForm({
checked={settings.oauthRequestRefreshToken ?? true}
onChange={handleRequestRefreshTokenChange}
/>
<Checkbox
label="Revoke tokens on clear"
description="Calls the authorization server's RFC 7009 revocation endpoint before clearing the stored OAuth state, so the grant ends when the session does instead of staying valid until it expires. Servers that advertise no revocation_endpoint are unaffected. Uncheck it to reproduce a client that disconnects still holding live tokens."
checked={settings.oauthRevokeOnClear ?? true}
onChange={handleRevokeOnClearChange}
/>
{refreshTokenOptedOut && scopesIncludeOfflineAccess ? (
<Alert color="yellow" title="offline_access is still requested">
The Scopes field above lists offline_access, and

View file

@ -156,6 +156,42 @@ describe("ServerSettingsModal", () => {
);
});
// #2144 — same omit-the-default shape as the refresh-token pair above.
it("maps the revoke-on-clear opt-out into settings, and back to unset", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const { rerender } = renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: /OAuth Settings/i }));
await user.click(screen.getByLabelText("Revoke tokens on clear"));
expect(onSettingsChange).toHaveBeenLastCalledWith(
expect.objectContaining({ oauthRevokeOnClear: false }),
);
rerender(
<ServerSettingsModal
opened
settings={{ ...initialSettings, oauthRevokeOnClear: false }}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByLabelText("Revoke tokens on clear"));
expect(onSettingsChange).toHaveBeenLastCalledWith(
expect.objectContaining({ oauthRevokeOnClear: undefined }),
);
});
it("maps the selected protocol era into settings (#1626)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();

View file

@ -191,6 +191,8 @@ export function ServerSettingsModal({
// server that never touched the switch writes no field at all.
oauthRequestRefreshToken:
oauth.requestRefreshToken === false ? false : undefined,
// #2144: same shape — `undefined` means on, so only the opt-out persists.
oauthRevokeOnClear: oauth.revokeOnClear === false ? false : undefined,
});
}

View file

@ -912,6 +912,11 @@ export function useConnectionLifecycle({
serverId === activeServerId ? inspectorClient : null,
isActiveConnection: serverId === activeServerId,
oauthStorage: webOAuthStorage,
// No RFC 7009 revocation here (#2144): this clears a *half-finished*
// flow so it can be retried. The authorization never completed, so
// there is no grant to revoke — and any token still on disk belongs
// to the very session this recovery is trying to rebuild.
revoke: false,
});
} catch (err) {
notifications.show({

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"));

View file

@ -5,6 +5,7 @@ import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { TypedEvent } from "@inspector/core/mcp/inspectorClientEventTarget.js";
import type {
ConnectionStatus,
InspectorServerSettings,
MCPServerConfig,
ServerEntry,
} from "@inspector/core/mcp/types.js";
@ -17,6 +18,7 @@ import {
} from "@inspector/core/auth/index.js";
import { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
import type {
AuthChallenge,
AuthChallengeReason,
@ -31,6 +33,7 @@ import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientCo
import type { OAuthDetails } from "../components/groups/ConnectionInfoContent/ConnectionInfoContent";
import { oauthDetailsFromConnectionState } from "../components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState";
import { getWebRemoteOAuthStorage } from "../lib/remoteOAuthStorage";
import { getWebProxiedFetch } from "../lib/webProxiedFetch";
import { clearServerOAuthState } from "../lib/clearServerOAuthState";
import { getAuthToken } from "../lib/authToken";
import {
@ -121,6 +124,36 @@ export interface ClearableServer {
id: string;
name: string;
config: MCPServerConfig;
/**
* Read for `oauthRevokeOnClear` (#2144). Optional so a caller that only has
* the identity fields still type-checks; an absent value means the default,
* on.
*/
settings?: InspectorServerSettings;
}
/**
* The sentence appended to the "OAuth state cleared" toast describing what the
* RFC 7009 leg did (#2144).
*
* Only the two outcomes a user can act on are surfaced. A success is worth
* saying because the whole point of the feature is invisible otherwise — the
* local clear looks identical either way. A failure is worth saying because the
* grant is still live at the authorization server and the user may want to
* revoke it by hand. The remaining skips ("this server advertises no revocation
* endpoint", "there were no tokens") describe the status quo and would turn a
* confirmation into a notice about something that did not need to happen.
*/
export function revocationSuffix(
outcome: TokenRevocationOutcome | undefined,
): string {
if (outcome?.status === "revoked") {
return " The grant was also revoked at the authorization server.";
}
if (outcome?.status === "failed") {
return ` Revoking the grant at the authorization server failed (${outcome.detail}), so it may still be valid there.`;
}
return "";
}
export interface UseOAuthRecoveryOptions {
@ -1221,11 +1254,13 @@ export function useOAuthRecovery({
const clearServerOAuthAndDisconnect = useCallback(
async (server: ClearableServer) => {
const isActive = server.id === activeServerId;
const cleared = await clearServerOAuthState({
const { cleared, revocation } = await clearServerOAuthState({
config: server.config,
inspectorClient: isActive ? inspectorClient : null,
isActiveConnection: isActive,
oauthStorage: webOAuthStorage,
revoke: server.settings?.oauthRevokeOnClear !== false,
fetchFn: getWebProxiedFetch(getAuthToken()),
});
if (!cleared) return;
@ -1243,8 +1278,8 @@ export function useOAuthRecovery({
notifications.show({
title: "OAuth state cleared",
message: isActive
? "Stored tokens and client registration were removed. Reconnect to run a fresh authorization flow."
: `Stored OAuth state was removed for "${server.name}". Connect to authorize again.`,
? `Stored tokens and client registration were removed. Reconnect to run a fresh authorization flow.${revocationSuffix(revocation)}`
: `Stored OAuth state was removed for "${server.name}". Connect to authorize again.${revocationSuffix(revocation)}`,
color: "blue",
});
},

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;
}

View file

@ -0,0 +1,407 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { OAuthMetadata } from "@modelcontextprotocol/client";
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
import {
DEFAULT_REVOCATION_TIMEOUT_MS,
buildRevocationRequest,
revocationAuthMethods,
revokeStoredOAuthTokens,
revokeToken,
selectRevocableToken,
} from "@inspector/core/auth/revocation.js";
const SERVER_URL = "https://mcp.example.com/mcp";
const REVOKE_URL = "https://as.example.com/revoke";
function metadata(over: Partial<OAuthMetadata> = {}): OAuthMetadata {
return {
issuer: "https://as.example.com",
authorization_endpoint: "https://as.example.com/authorize",
token_endpoint: "https://as.example.com/token",
response_types_supported: ["code"],
revocation_endpoint: REVOKE_URL,
...over,
} as OAuthMetadata;
}
function body(init: RequestInit): URLSearchParams {
return new URLSearchParams(String(init.body));
}
function headerOf(init: RequestInit, name: string): string | undefined {
return (init.headers as Record<string, string> | undefined)?.[name];
}
describe("selectRevocableToken", () => {
// RFC 7009 §2.1 — revoking the refresh token asks the AS to invalidate the
// access tokens under the same grant, so one request covers both. Naming the
// access token instead would leave the long-lived half alive.
it("prefers the refresh token when one exists", () => {
expect(
selectRevocableToken({
access_token: "a",
token_type: "Bearer",
refresh_token: "r",
}),
).toEqual({ token: "r", tokenTypeHint: "refresh_token" });
});
it("falls back to the access token", () => {
expect(
selectRevocableToken({ access_token: "a", token_type: "Bearer" }),
).toEqual({ token: "a", tokenTypeHint: "access_token" });
});
it("has nothing to revoke without tokens", () => {
expect(selectRevocableToken(undefined)).toBeNull();
expect(
selectRevocableToken({ access_token: "", token_type: "Bearer" }),
).toBeNull();
});
});
describe("revocationAuthMethods", () => {
it("prefers the revocation endpoint's own list", () => {
expect(
revocationAuthMethods(
metadata({
revocation_endpoint_auth_methods_supported: ["client_secret_post"],
token_endpoint_auth_methods_supported: ["client_secret_basic"],
}),
),
).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", () => {
expect(
revocationAuthMethods(
metadata({ token_endpoint_auth_methods_supported: ["none"] }),
),
).toEqual(["none"]);
});
it("yields nothing when neither is advertised", () => {
expect(revocationAuthMethods(metadata())).toEqual([]);
});
});
describe("buildRevocationRequest", () => {
it("posts a form-encoded token and hint", () => {
const { url, init } = buildRevocationRequest({
endpoint: REVOKE_URL,
token: "r",
tokenTypeHint: "refresh_token",
supportedAuthMethods: [],
});
expect(url).toBe(REVOKE_URL);
expect(init.method).toBe("POST");
expect(headerOf(init, "Content-Type")).toBe(
"application/x-www-form-urlencoded",
);
expect(body(init).get("token")).toBe("r");
expect(body(init).get("token_type_hint")).toBe("refresh_token");
});
it("sends a confidential client's secret in the Authorization header for client_secret_basic", () => {
const { init } = buildRevocationRequest({
endpoint: REVOKE_URL,
token: "r",
tokenTypeHint: "refresh_token",
clientInformation: { client_id: "id one", client_secret: "s/ecret" },
supportedAuthMethods: ["client_secret_basic"],
});
// Byte-identical to the SDK's `applyBasicAuth`: the raw `id:secret`, not the
// form-urlencoded pair RFC 6749 §2.3.1 asks for. Presenting the credential
// differently here than at the token endpoint is what would let an
// authorization server accept one request and reject the other.
expect(headerOf(init, "Authorization")).toBe(
`Basic ${btoa("id one:s/ecret")}`,
);
expect(body(init).has("client_secret")).toBe(false);
});
it("sends the secret in the body for client_secret_post", () => {
const { init } = buildRevocationRequest({
endpoint: REVOKE_URL,
token: "r",
tokenTypeHint: "refresh_token",
clientInformation: { client_id: "id", client_secret: "s" },
supportedAuthMethods: ["client_secret_post"],
});
expect(headerOf(init, "Authorization")).toBeUndefined();
expect(body(init).get("client_id")).toBe("id");
expect(body(init).get("client_secret")).toBe("s");
});
it("identifies a public client by client_id alone", () => {
const { init } = buildRevocationRequest({
endpoint: REVOKE_URL,
token: "a",
tokenTypeHint: "access_token",
clientInformation: { client_id: "public" },
supportedAuthMethods: ["none"],
});
expect(headerOf(init, "Authorization")).toBeUndefined();
expect(body(init).get("client_id")).toBe("public");
expect(body(init).has("client_secret")).toBe(false);
});
});
describe("revokeToken", () => {
it("reports a 200 as revoked", async () => {
const fetchFn = vi.fn<typeof fetch>(
async () => new Response(null, { status: 200 }),
);
await expect(
revokeToken({
endpoint: REVOKE_URL,
token: "r",
tokenTypeHint: "refresh_token",
supportedAuthMethods: [],
fetchFn,
}),
).resolves.toEqual({
status: "revoked",
tokenTypeHint: "refresh_token",
endpoint: REVOKE_URL,
});
});
it("reports a non-2xx as failed rather than throwing", async () => {
const fetchFn = vi.fn<typeof fetch>(
async () =>
new Response(null, { status: 401, statusText: "Unauthorized" }),
);
const outcome = await revokeToken({
endpoint: REVOKE_URL,
token: "r",
tokenTypeHint: "refresh_token",
supportedAuthMethods: [],
fetchFn,
});
expect(outcome).toMatchObject({ status: "failed", endpoint: REVOKE_URL });
expect(outcome.status === "failed" ? outcome.detail : "").toContain("401");
});
it("reports a network failure as failed", async () => {
const fetchFn = vi.fn<typeof fetch>(async () => {
throw new Error("connect ECONNREFUSED");
});
const outcome = await revokeToken({
endpoint: REVOKE_URL,
token: "r",
tokenTypeHint: "refresh_token",
supportedAuthMethods: [],
fetchFn,
});
expect(outcome).toEqual({
status: "failed",
endpoint: REVOKE_URL,
detail: "connect ECONNREFUSED",
});
});
// 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 () => {
let seen: RequestInit | undefined;
const fetchFn = vi.fn<typeof fetch>(async (_url, init) => {
seen = init;
return new Response(null, { status: 200 });
});
await revokeToken({
endpoint: REVOKE_URL,
token: "r",
tokenTypeHint: "refresh_token",
supportedAuthMethods: [],
fetchFn,
timeoutMs: 1,
});
expect(seen?.signal).toBeInstanceOf(AbortSignal);
expect(DEFAULT_REVOCATION_TIMEOUT_MS).toBeGreaterThan(0);
});
});
describe("revokeStoredOAuthTokens", () => {
let storage: BrowserOAuthStorage;
beforeEach(async () => {
storage = new BrowserOAuthStorage();
await storage.clear(SERVER_URL);
});
async function seed(over: Partial<OAuthMetadata> = {}): Promise<void> {
await storage.saveTokens(SERVER_URL, {
access_token: "a",
token_type: "Bearer",
refresh_token: "r",
});
await storage.saveServerMetadata(SERVER_URL, metadata(over));
}
it("revokes the stored refresh token", async () => {
await seed();
await storage.saveClientInformation(
SERVER_URL,
{ client_id: "cid", client_secret: "sec" },
{ registrationKind: "dcr" },
);
const fetchFn = vi.fn<typeof fetch>(
async () => new Response(null, { status: 200 }),
);
const outcome = await revokeStoredOAuthTokens({
serverUrl: SERVER_URL,
storage,
fetchFn,
});
expect(outcome).toMatchObject({ status: "revoked" });
const [, init] = fetchFn.mock.calls[0]!;
expect(body(init!).get("token")).toBe("r");
});
// A server configured with `oauth.clientId` stores its credentials in the
// preregistered slot, which is issuer-independent and is *not* what a plain
// `getClientInformation(serverUrl)` returns. Reading only the dynamic slot
// would send no client authentication at all for exactly the confidential
// clients most likely to require it.
it("authenticates with a preconfigured client, not just a dynamically registered one", async () => {
await seed();
await storage.savePreregisteredClientInformation(SERVER_URL, {
client_id: "static-cid",
client_secret: "static-sec",
});
const fetchFn = vi.fn<typeof fetch>(
async () => new Response(null, { status: 200 }),
);
await revokeStoredOAuthTokens({ serverUrl: SERVER_URL, storage, fetchFn });
const [, init] = fetchFn.mock.calls[0]!;
expect(headerOf(init!, "Authorization")).toBe(
`Basic ${btoa("static-cid:static-sec")}`,
);
});
// The whole point of keeping this path opt-out-able: an authorization server
// with no RFC 7009 support must behave exactly as it did before the feature.
it("does nothing when the authorization server advertises no revocation endpoint", async () => {
await seed({ revocation_endpoint: undefined });
const fetchFn = vi.fn<typeof fetch>();
await expect(
revokeStoredOAuthTokens({ serverUrl: SERVER_URL, storage, fetchFn }),
).resolves.toEqual({ status: "skipped", reason: "no_endpoint" });
expect(fetchFn).not.toHaveBeenCalled();
});
it("does nothing when no metadata was ever discovered", async () => {
await storage.saveTokens(SERVER_URL, {
access_token: "a",
token_type: "Bearer",
});
const fetchFn = vi.fn<typeof fetch>();
await expect(
revokeStoredOAuthTokens({ serverUrl: SERVER_URL, storage, fetchFn }),
).resolves.toEqual({ status: "skipped", reason: "no_metadata" });
expect(fetchFn).not.toHaveBeenCalled();
});
it("does nothing when there is no stored grant", async () => {
const fetchFn = vi.fn<typeof fetch>();
await expect(
revokeStoredOAuthTokens({ serverUrl: SERVER_URL, storage, fetchFn }),
).resolves.toEqual({ status: "skipped", reason: "no_tokens" });
expect(fetchFn).not.toHaveBeenCalled();
});
it("skips the request when disabled", async () => {
await seed();
const fetchFn = vi.fn<typeof fetch>();
await expect(
revokeStoredOAuthTokens({
serverUrl: SERVER_URL,
storage,
fetchFn,
enabled: false,
}),
).resolves.toEqual({ status: "skipped", reason: "disabled" });
expect(fetchFn).not.toHaveBeenCalled();
});
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 fetchFn = vi.fn<typeof fetch>(
async () => new Response(null, { status: 500, statusText: "Boom" }),
);
const outcome = await revokeStoredOAuthTokens({
serverUrl: SERVER_URL,
storage,
fetchFn,
logger: logger as unknown as Parameters<
typeof revokeStoredOAuthTokens
>[0]["logger"],
});
expect(outcome.status).toBe("failed");
expect(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(),
};
await revokeStoredOAuthTokens({
serverUrl: SERVER_URL,
storage,
fetchFn: vi.fn<typeof fetch>(),
logger: logger as unknown as Parameters<
typeof revokeStoredOAuthTokens
>[0]["logger"],
});
expect(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.
it("reports a store read failure as failed", async () => {
const broken = {
...storage,
getTokens: () => Promise.reject(new Error("store unreadable")),
} as unknown as BrowserOAuthStorage;
await expect(
revokeStoredOAuthTokens({
serverUrl: SERVER_URL,
storage: broken,
fetchFn: vi.fn<typeof fetch>(),
}),
).resolves.toEqual({ status: "failed", detail: "store unreadable" });
});
});

View file

@ -197,6 +197,88 @@ describe("OAuthManager", () => {
expect(manager.getOAuthFlowStep()).toBeUndefined();
});
// #2144 — the ordering is the contract, not an implementation detail: the
// revocation request is built from the token, the client id and the cached
// metadata that `clear` is about to delete.
it("revokes at the authorization server before clearing local state", async () => {
const params = createMockParams();
const storage = params.initialConfig.storage!;
vi.mocked(storage.getTokens).mockResolvedValue({
access_token: "a",
token_type: "Bearer",
refresh_token: "r",
});
vi.mocked(storage.getServerMetadata).mockResolvedValue({
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"],
});
const order: string[] = [];
vi.mocked(storage.clear).mockImplementation(async () => {
order.push("clear");
});
const fetchFn = vi.fn<typeof fetch>(async () => {
order.push("revoke");
return new Response(null, { status: 200 });
});
const manager = new OAuthManager({
...params,
effectiveAuthFetch: fetchFn,
});
await expect(manager.clearOAuthTokens()).resolves.toMatchObject({
status: "revoked",
tokenTypeHint: "refresh_token",
});
expect(order).toEqual(["revoke", "clear"]);
});
it("clears local state even when the revocation request fails", async () => {
const params = createMockParams();
const storage = params.initialConfig.storage!;
vi.mocked(storage.getTokens).mockResolvedValue({
access_token: "a",
token_type: "Bearer",
});
vi.mocked(storage.getServerMetadata).mockResolvedValue({
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"],
});
const manager = new OAuthManager({
...params,
effectiveAuthFetch: vi.fn<typeof fetch>(async () => {
throw new Error("unreachable");
}),
});
await expect(manager.clearOAuthTokens()).resolves.toMatchObject({
status: "failed",
});
expect(storage.clear).toHaveBeenCalledWith(SERVER_URL);
});
it("skips the request when revocation is turned off", async () => {
const params = createMockParams();
const fetchFn = vi.fn<typeof fetch>();
const manager = new OAuthManager({
...params,
effectiveAuthFetch: fetchFn,
});
await expect(
manager.clearOAuthTokens({ revoke: false }),
).resolves.toEqual({ status: "skipped", reason: "disabled" });
expect(fetchFn).not.toHaveBeenCalled();
expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith(
SERVER_URL,
);
});
it("no-ops when storage is not configured", async () => {
const params = createMockParams({
initialConfig: {
@ -207,7 +289,10 @@ describe("OAuthManager", () => {
} as OAuthManagerConfig,
});
const manager = new OAuthManager(params);
await manager.clearOAuthTokens();
await expect(manager.clearOAuthTokens()).resolves.toEqual({
status: "skipped",
reason: "no_tokens",
});
expect(params.getServerUrl).not.toHaveBeenCalled();
});
});

View file

@ -1261,6 +1261,73 @@ describe("oauth.requestRefreshToken (#2068)", () => {
});
});
// #2144 — same inverted-default shape as `requestRefreshToken` above: on by
// default, and only the opt-out is written, so an entry that never touched the
// switch keeps a byte-stable round-trip.
describe("oauthRevokeOnClear (RFC 7009)", () => {
const baseSettings = {
headers: [],
env: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 1000,
roots: [],
};
it("lifts an explicit false to settings.oauthRevokeOnClear", () => {
expect(
storedFieldsToInspectorSettings({
oauth: { clientId: "cid", revokeOnClear: false },
})?.oauthRevokeOnClear,
).toBe(false);
});
it("leaves the setting unset when the field is absent (default: on)", () => {
expect(
storedFieldsToInspectorSettings({ oauth: { clientId: "cid" } })
?.oauthRevokeOnClear,
).toBeUndefined();
});
it("leaves the setting unset for an explicit true, which means the default", () => {
expect(
storedFieldsToInspectorSettings({
oauth: { clientId: "cid", revokeOnClear: true },
})?.oauthRevokeOnClear,
).toBeUndefined();
});
it("persists the opt-out under oauth on disk", () => {
expect(
inspectorSettingsToStoredFields({
...baseSettings,
oauthRevokeOnClear: false,
}).oauth?.revokeOnClear,
).toBe(false);
});
it("writes nothing when the setting is on", () => {
expect(
inspectorSettingsToStoredFields({
...baseSettings,
oauthRevokeOnClear: true,
}).oauth,
).toBeUndefined();
});
it("round-trips the opt-out", () => {
const stored = inspectorSettingsToStoredFields({
...baseSettings,
oauthRevokeOnClear: false,
});
expect(storedFieldsToInspectorSettings(stored)?.oauthRevokeOnClear).toBe(
false,
);
});
});
describe("oauthOnInsufficientScope (SEP-2350)", () => {
const baseSettings = {
headers: [],

View file

@ -0,0 +1,189 @@
/**
* RFC 7009 revocation against the real OAuth test server (#2144).
*
* The unit suite (`src/test/core/auth/revocation.test.ts`) pins what is *sent*;
* this pins what the request actually *does*. The two halves that only a real
* authorization server can show are both here: that the Inspector's request is
* accepted at all (its client authentication and form encoding are right), and
* that §2.1's grant linkage holds — one request naming the refresh token kills
* the access token issued alongside it, which is the whole reason the Inspector
* sends one request rather than two.
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createHash, randomBytes } from "node:crypto";
import {
TestServerHttp,
createOAuthTestServerConfig,
getDefaultServerConfig,
waitForOAuthWellKnown,
} from "@modelcontextprotocol/inspector-test-server";
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
import { revokeStoredOAuthTokens } from "@inspector/core/auth/revocation.js";
import type { OAuthMetadata } from "@modelcontextprotocol/client";
const CLIENT_ID = "test-2144-revocation";
const CLIENT_SECRET = "test-2144-secret";
const REDIRECT_URL = "http://localhost:3000/oauth/callback";
function base64Url(buffer: Buffer): string {
return buffer
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
describe("OAuth token revocation (RFC 7009)", () => {
let mcpServer: TestServerHttp | null = null;
let serverUrl = "";
let metadata: OAuthMetadata;
beforeAll(async () => {
mcpServer = new TestServerHttp({
...getDefaultServerConfig(),
serverType: "streamable-http" as const,
...createOAuthTestServerConfig({
requireAuth: true,
staticClients: [
{
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
redirectUris: [REDIRECT_URL],
},
],
}),
});
const port = await mcpServer.start();
serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl);
metadata = (await (
await fetch(`${serverUrl}/.well-known/oauth-authorization-server`)
).json()) as OAuthMetadata;
}, 30_000);
afterAll(async () => {
await mcpServer?.stop();
mcpServer = null;
}, 30_000);
/** Run a real authorization-code exchange and return the issued tokens. */
async function authorize(): Promise<{
access_token: string;
refresh_token: string;
}> {
const verifier = base64Url(randomBytes(32));
const challenge = base64Url(createHash("sha256").update(verifier).digest());
const authorizeResponse = await fetch(`${serverUrl}/oauth/authorize`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
redirect: "manual",
body: new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URL,
response_type: "code",
scope: "mcp",
code_challenge: challenge,
code_challenge_method: "S256",
}),
});
const location = authorizeResponse.headers.get("location");
expect(location).toBeTruthy();
const code = new URL(location!).searchParams.get("code");
expect(code).toBeTruthy();
const tokenResponse = await fetch(`${serverUrl}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: code!,
redirect_uri: REDIRECT_URL,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code_verifier: verifier,
}),
});
expect(tokenResponse.status).toBe(200);
return (await tokenResponse.json()) as {
access_token: string;
refresh_token: string;
};
}
/** Whether the MCP endpoint still accepts this bearer token. */
async function tokenAccepted(accessToken: string): Promise<boolean> {
const response = await fetch(`${serverUrl}/mcp`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.status !== 401;
}
async function seededStorage(tokens: {
access_token: string;
refresh_token?: string;
}): Promise<BrowserOAuthStorage> {
const storage = new BrowserOAuthStorage();
await storage.clear(serverUrl);
await storage.saveTokens(serverUrl, { token_type: "Bearer", ...tokens });
await storage.saveServerMetadata(serverUrl, metadata);
// Preconfigured, so it goes in the preregistered slot — the same one a
// server with `oauth.clientId` uses. That is the slot the revocation path
// must read first, or a confidential client sends no authentication at all.
await storage.savePreregisteredClientInformation(serverUrl, {
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
});
return storage;
}
it("advertises a revocation endpoint", () => {
expect(metadata.revocation_endpoint).toBe(`${serverUrl}/oauth/revoke`);
});
// The failure this feature exists to fix: without the request, the token is
// still accepted after the Inspector has forgotten it.
it("revokes the whole grant from the stored refresh token", async () => {
const tokens = await authorize();
expect(await tokenAccepted(tokens.access_token)).toBe(true);
const outcome = await revokeStoredOAuthTokens({
serverUrl,
storage: await seededStorage(tokens),
fetchFn: fetch,
});
expect(outcome).toMatchObject({
status: "revoked",
tokenTypeHint: "refresh_token",
});
// RFC 7009 §2.1 — the access token issued under the same grant dies too,
// which is why one request is enough.
expect(await tokenAccepted(tokens.access_token)).toBe(false);
});
it("revokes an access token when no refresh token was issued", async () => {
const tokens = await authorize();
const outcome = await revokeStoredOAuthTokens({
serverUrl,
storage: await seededStorage({ access_token: tokens.access_token }),
fetchFn: fetch,
});
expect(outcome).toMatchObject({
status: "revoked",
tokenTypeHint: "access_token",
});
expect(await tokenAccepted(tokens.access_token)).toBe(false);
});
// RFC 7009 §2.2: an unknown token is a success, so revoking a grant the
// server has already expired must not be reported as a failure.
it("treats an already-unknown token as revoked", async () => {
const storage = await seededStorage({ access_token: "never-issued" });
await expect(
revokeStoredOAuthTokens({ serverUrl, storage, fetchFn: fetch }),
).resolves.toMatchObject({ status: "revoked" });
});
});

View file

@ -5447,8 +5447,12 @@ describe("InspectorClient", () => {
expect(c.getOAuthFlowStep()).toBeUndefined();
expect(c.getOAuthFlowState()).toBeUndefined();
await expect(c.getOAuthState()).resolves.toBeUndefined();
// clearOAuthTokens is a no-op when there is no manager
await expect(c.clearOAuthTokens()).resolves.toBeUndefined();
// clearOAuthTokens is a no-op when there is no manager; it still
// reports an outcome so callers have one shape to read (#2144).
await expect(c.clearOAuthTokens()).resolves.toEqual({
status: "skipped",
reason: "no_tokens",
});
});
it("setOAuthConfig throws when oauthManager is unset", () => {

View file

@ -639,6 +639,11 @@ describe("server.ts supplemental coverage", () => {
expect((await res.json()).error).toMatch(/oauthRequestRefreshToken/);
});
it("rejects a non-boolean oauthRevokeOnClear (#2144)", async () => {
const res = await postSettings({ ...base, oauthRevokeOnClear: "no" });
expect((await res.json()).error).toMatch(/oauthRevokeOnClear/);
});
it("rejects malformed roots", async () => {
const res = await postSettings({ ...base, roots: [{ uri: 1 }] });
expect((await res.json()).error).toMatch(/roots/);
@ -708,6 +713,32 @@ describe("server.ts supplemental coverage", () => {
expect(body.mcpServers.srv?.oauth?.requestRefreshToken).toBeUndefined();
});
// #2144 — same reasoning as the refresh-token pair above: a 200 only proves
// the payload validated, not that the field survived the write-through.
it("persists the revoke-on-clear opt-out through a save", async () => {
expect(
(await postSettings({ ...base, oauthRevokeOnClear: false })).status,
).toBe(200);
const res = await fetch(`${h.baseUrl}/api/servers`);
const body = (await res.json()) as {
mcpServers: Record<string, { oauth?: { revokeOnClear?: boolean } }>;
};
expect(body.mcpServers.srv?.oauth?.revokeOnClear).toBe(false);
});
it("writes no revoke-on-clear field when the setting is on", async () => {
expect(
(await postSettings({ ...base, oauthRevokeOnClear: true })).status,
).toBe(200);
const res = await fetch(`${h.baseUrl}/api/servers`);
const body = (await res.json()) as {
mcpServers: Record<string, { oauth?: Record<string, unknown> }>;
};
expect(body.mcpServers.srv?.oauth?.revokeOnClear).toBeUndefined();
});
it("accepts a fully-populated valid settings payload", async () => {
const res = await postSettings({
...base,
@ -724,6 +755,7 @@ describe("server.ts supplemental coverage", () => {
oauthScopes: "a b",
enterpriseManaged: true,
oauthRequestRefreshToken: false,
oauthRevokeOnClear: false,
roots: [{ uri: "file:///x", name: "x" }],
});
expect(res.status).toBe(200);
@ -892,6 +924,30 @@ describe("server.ts supplemental coverage", () => {
}
});
// #2144 — same all-or-nothing rule for the revocation opt-out.
it("drops oauth whose revokeOnClear is not a boolean", async () => {
const h = await start({
seedConfig: JSON.stringify({
mcpServers: {
srv: {
type: "streamable-http",
url: "https://x.test/mcp",
oauth: { revokeOnClear: "no" },
},
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers`);
const body = (await res.json()) as {
mcpServers: Record<string, Record<string, unknown>>;
};
expect(body.mcpServers.srv).not.toHaveProperty("oauth");
} finally {
await stop(h);
}
});
it("keeps a well-formed requestRefreshToken opt-out on read (#2068)", async () => {
const h = await start({
seedConfig: JSON.stringify({