inspector/clients/web/src/test/integration/auth/revocation-e2e.test.ts
cliffhall 65fd2b9e35 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>
2026-08-28 00:48:57 -04:00

189 lines
6.6 KiB
TypeScript

/**
* 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" });
});
});