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:
parent
baff393675
commit
65fd2b9e35
6 changed files with 2159 additions and 46 deletions
407
clients/web/src/test/core/auth/revocation.test.ts
Normal file
407
clients/web/src/test/core/auth/revocation.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
189
clients/web/src/test/integration/auth/revocation-e2e.test.ts
Normal file
189
clients/web/src/test/integration/auth/revocation-e2e.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue