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
4 changed files with 2159 additions and 46 deletions
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
58
clients/web/src/lib/webProxiedFetch.test.ts
Normal file
58
clients/web/src/lib/webProxiedFetch.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
44
clients/web/src/lib/webProxiedFetch.ts
Normal file
44
clients/web/src/lib/webProxiedFetch.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue