Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 182 additions & 1 deletion client/src/lib/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { discoverScopes } from "../auth";
import { discoverScopes, revokeTokens } from "../auth";
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "../constants";

jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
discoverAuthorizationServerMetadata: jest.fn(),
Expand Down Expand Up @@ -156,3 +157,183 @@ describe("discoverScopes", () => {
},
);
});

describe("revokeTokens", () => {
const serverUrl = "https://example.com";
const revocationEndpoint = "https://test.com/revoke";
const metadataWithRevocation = {
...baseMetadata,
revocation_endpoint: revocationEndpoint,
};

const seedTokens = (tokens: {
access_token: string;
token_type?: string;
refresh_token?: string;
}) => {
sessionStorage.setItem(
getServerSpecificKey(SESSION_KEYS.TOKENS, serverUrl),
JSON.stringify({ token_type: "Bearer", ...tokens }),
);
};

const seedClientInfo = (
client_id: string,
{ isPreregistered = false } = {},
) => {
const key = getServerSpecificKey(
isPreregistered
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
: SESSION_KEYS.CLIENT_INFORMATION,
serverUrl,
);
sessionStorage.setItem(key, JSON.stringify({ client_id }));
};

const parseRevokeBody = (call: [unknown, RequestInit | undefined]) => {
const init = call[1];
return new URLSearchParams(init?.body as string);
};

let warnSpy: jest.SpyInstance;
let debugSpy: jest.SpyInstance;

beforeEach(() => {
jest.clearAllMocks();
sessionStorage.clear();
warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
debugSpy = jest.spyOn(console, "debug").mockImplementation(() => {});
});

afterEach(() => {
warnSpy.mockRestore();
debugSpy.mockRestore();
});

it("posts refresh_token to revocation_endpoint when available, includes client_id", async () => {
mockDiscoverAuth.mockResolvedValue(metadataWithRevocation);
seedTokens({ access_token: "at-123", refresh_token: "rt-456" });
seedClientInfo("client-xyz");
const fetchFn = jest
.fn<Promise<Response>, [RequestInfo | URL, RequestInit?]>()
.mockResolvedValue(new Response(null, { status: 200 }));

await revokeTokens({ serverUrl, fetchFn });

expect(fetchFn).toHaveBeenCalledTimes(1);
const [url, init] = fetchFn.mock.calls[0];
expect(url).toBe(revocationEndpoint);
expect(init?.method).toBe("POST");
expect((init?.headers as Record<string, string>)["Content-Type"]).toBe(
"application/x-www-form-urlencoded",
);
const body = parseRevokeBody(fetchFn.mock.calls[0]);
expect(body.get("token")).toBe("rt-456");
expect(body.get("token_type_hint")).toBe("refresh_token");
expect(body.get("client_id")).toBe("client-xyz");
});

it("prefers preregistered client_id over dynamic", async () => {
mockDiscoverAuth.mockResolvedValue(metadataWithRevocation);
seedTokens({ access_token: "at-123", refresh_token: "rt-456" });
seedClientInfo("dynamic-client");
seedClientInfo("preregistered-client", { isPreregistered: true });
const fetchFn = jest
.fn<Promise<Response>, [RequestInfo | URL, RequestInit?]>()
.mockResolvedValue(new Response(null, { status: 200 }));

await revokeTokens({ serverUrl, fetchFn });

const body = parseRevokeBody(fetchFn.mock.calls[0]);
expect(body.get("client_id")).toBe("preregistered-client");
});

it("falls back to access_token when no refresh_token is present", async () => {
mockDiscoverAuth.mockResolvedValue(metadataWithRevocation);
seedTokens({ access_token: "at-only" });
const fetchFn = jest
.fn<Promise<Response>, [RequestInfo | URL, RequestInit?]>()
.mockResolvedValue(new Response(null, { status: 200 }));

await revokeTokens({ serverUrl, fetchFn });

expect(fetchFn).toHaveBeenCalledTimes(1);
const body = parseRevokeBody(fetchFn.mock.calls[0]);
expect(body.get("token")).toBe("at-only");
expect(body.get("token_type_hint")).toBe("access_token");
expect(body.get("client_id")).toBeNull();
});

it("no-ops when no tokens are stored", async () => {
const fetchFn = jest.fn<
Promise<Response>,
[RequestInfo | URL, RequestInit?]
>();

await revokeTokens({ serverUrl, fetchFn });

expect(fetchFn).not.toHaveBeenCalled();
expect(mockDiscoverAuth).not.toHaveBeenCalled();
});

it("no-ops when AS metadata has no revocation_endpoint", async () => {
mockDiscoverAuth.mockResolvedValue(baseMetadata);
seedTokens({ access_token: "at-123", refresh_token: "rt-456" });
const fetchFn = jest.fn<
Promise<Response>,
[RequestInfo | URL, RequestInit?]
>();

await revokeTokens({ serverUrl, fetchFn });

expect(fetchFn).not.toHaveBeenCalled();
});

it("swallows fetch rejection and logs a warning", async () => {
mockDiscoverAuth.mockResolvedValue(metadataWithRevocation);
seedTokens({ access_token: "at-123", refresh_token: "rt-456" });
const fetchFn = jest
.fn<Promise<Response>, [RequestInfo | URL, RequestInit?]>()
.mockRejectedValue(new Error("network down"));

await expect(revokeTokens({ serverUrl, fetchFn })).resolves.toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith(
"Token revocation failed (best-effort):",
expect.any(Error),
);
});

it("treats non-2xx response as a soft failure without throwing", async () => {
mockDiscoverAuth.mockResolvedValue(metadataWithRevocation);
seedTokens({ access_token: "at-123", refresh_token: "rt-456" });
const fetchFn = jest
.fn<Promise<Response>, [RequestInfo | URL, RequestInit?]>()
.mockResolvedValue(
new Response("nope", { status: 400, statusText: "Bad Request" }),
);

await expect(revokeTokens({ serverUrl, fetchFn })).resolves.toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Token revocation responded 400"),
);
});

it("uses the provided fetchFn, not the global fetch", async () => {
mockDiscoverAuth.mockResolvedValue(metadataWithRevocation);
seedTokens({ access_token: "at-123", refresh_token: "rt-456" });
const globalFetchSpy = jest
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(null, { status: 200 }));
const fetchFn = jest
.fn<Promise<Response>, [RequestInfo | URL, RequestInit?]>()
.mockResolvedValue(new Response(null, { status: 200 }));

try {
await revokeTokens({ serverUrl, fetchFn });
expect(fetchFn).toHaveBeenCalledTimes(1);
expect(globalFetchSpy).not.toHaveBeenCalled();
} finally {
globalFetchSpy.mockRestore();
}
});
});
86 changes: 86 additions & 0 deletions client/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,92 @@ export const clearScopeFromSessionStorage = (serverUrl: string) => {
sessionStorage.removeItem(key);
};

/**
* Best-effort RFC 7009 token revocation. Called on user-initiated disconnect so
* the authorization server can invalidate the access/refresh token rather than
* waiting for natural expiry. Per RFC 7009 §2.1, revoking a refresh token also
* invalidates associated access tokens, so we prefer the refresh token when
* present and fall back to the access token otherwise.
*
* Never throws: if there are no saved tokens, no advertised revocation_endpoint,
* or the POST fails for any reason, this resolves quietly. A 3s timeout keeps a
* slow AS from blocking the UI.
*/
export const revokeTokens = async ({
serverUrl,
fetchFn,
}: {
serverUrl: string;
fetchFn?: typeof fetch;
}): Promise<void> => {
try {
const tokensRaw = sessionStorage.getItem(
getServerSpecificKey(SESSION_KEYS.TOKENS, serverUrl),
);
if (!tokensRaw) {
return;
}
const tokens = await OAuthTokensSchema.parseAsync(JSON.parse(tokensRaw));

const metadata = await discoverAuthorizationServerMetadata(
new URL("/", serverUrl),
{ fetchFn },
);
// `revocation_endpoint` is declared on OAuthMetadata but not on the OIDC
// branch of the union, even though OIDC providers may advertise it at
// runtime per RFC 8414. Narrow with `in` so we read it from either shape.
const revocationEndpoint =
metadata && "revocation_endpoint" in metadata
? (metadata as { revocation_endpoint?: string }).revocation_endpoint
: undefined;
if (!revocationEndpoint) {
return;
}

const token = tokens.refresh_token ?? tokens.access_token;
const tokenTypeHint = tokens.refresh_token
? "refresh_token"
: "access_token";

const body = new URLSearchParams();
body.set("token", token);
body.set("token_type_hint", tokenTypeHint);

// Public-client convention: include client_id in the form body. Try
// preregistered first, then dynamically registered (same priority as
// InspectorOAuthClientProvider.clientInformation()).
const clientInfo =
(await getClientInformationFromSessionStorage({
serverUrl,
isPreregistered: true,
})) ??
(await getClientInformationFromSessionStorage({
serverUrl,
isPreregistered: false,
}));
if (clientInfo?.client_id) {
body.set("client_id", clientInfo.client_id);
}

const response = await (fetchFn ?? fetch)(revocationEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
signal: AbortSignal.timeout(3000),
});

if (!response.ok) {
console.warn(
`Token revocation responded ${response.status} ${response.statusText} (best-effort, continuing)`,
);
return;
}
console.debug("Token revocation succeeded");
} catch (error) {
console.warn("Token revocation failed (best-effort):", error);
}
};

export class InspectorOAuthClientProvider implements OAuthClientProvider {
constructor(protected serverUrl: string) {
// Save the server URL to session storage
Expand Down
8 changes: 8 additions & 0 deletions client/src/lib/configurationTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,12 @@ export type InspectorConfig = {
* Default Time-to-Live (TTL) in milliseconds for newly created tasks.
*/
MCP_TASK_TTL: ConfigItem;

/**
* Whether to send an RFC 7009 token revocation request to the authorization server
* on Disconnect (when the server advertises a `revocation_endpoint`). Default `true`
* (spec-compliant). Disable to test server behavior when a client disconnects
* without revoking, or to suppress the network call during offline testing.
*/
MCP_OAUTH_REVOKE_ON_DISCONNECT: ConfigItem;
};
7 changes: 7 additions & 0 deletions client/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,11 @@ export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
value: 60000,
is_session_item: false,
},
MCP_OAUTH_REVOKE_ON_DISCONNECT: {
label: "Revoke OAuth Tokens on Disconnect",
description:
"When disconnecting, send an RFC 7009 token revocation request to the authorization server before clearing local state. Disable to test how a server behaves when a client disconnects without revoking.",
value: true,
is_session_item: false,
},
} as const;
Loading