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>
189 lines
6.6 KiB
TypeScript
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" });
|
|
});
|
|
});
|