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

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({