From c90436212a95de42b79d1c1c1a5fdd48762a9476 Mon Sep 17 00:00:00 2001 From: kavin <51290376+kavin-kr@users.noreply.github.com> Date: Fri, 4 Jul 2025 13:10:06 -0700 Subject: [PATCH] Add revokeToken() function for client Implement the revokeToken() function in the client library to allow clients to revoke tokens on the server. --- src/client/auth.test.ts | 182 ++++++++++++++++++++++++++++++++++++++++ src/client/auth.ts | 60 +++++++++++++ 2 files changed, 242 insertions(+) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 8155e134..55f1bc43 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -8,6 +8,7 @@ import { discoverOAuthProtectedResourceMetadata, extractResourceMetadataUrl, auth, + revokeToken, type OAuthClientProvider, } from "./auth.js"; @@ -1477,4 +1478,185 @@ describe("OAuth Authorization", () => { expect(body.get("refresh_token")).toBe("refresh123"); }); }); + + describe("revokeToken", () => { + const validClientInfo = { + client_id: "client123", + client_secret: "secret123", + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Test Client", + }; + + const validMetadata = { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + revocation_endpoint: "https://auth.example.com/revoke", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }; + + it("revokes access token successfully", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + await revokeToken("https://auth.example.com", { + clientInformation: validClientInfo, + token: "access_token_123", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://auth.example.com/revoke", + }), + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }) + ); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get("token")).toBe("access_token_123"); + expect(body.get("client_id")).toBe("client123"); + expect(body.get("client_secret")).toBe("secret123"); + expect(body.has("token_type_hint")).toBe(false); + }); + + it("revokes refresh token successfully", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + await revokeToken("https://auth.example.com", { + clientInformation: validClientInfo, + token: "refresh_token_123", + tokenTypeHint: "refresh_token", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://auth.example.com/revoke", + }), + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }) + ); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get("token")).toBe("refresh_token_123"); + expect(body.get("token_type_hint")).toBe("refresh_token"); + expect(body.get("client_id")).toBe("client123"); + expect(body.get("client_secret")).toBe("secret123"); + }); + + it("uses revocation_endpoint from metadata when provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + await revokeToken("https://auth.example.com", { + metadata: validMetadata, + clientInformation: validClientInfo, + token: "access_token_123", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://auth.example.com/revoke", + }), + expect.any(Object) + ); + }); + + it("falls back to /revoke endpoint when metadata not provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + await revokeToken("https://auth.example.com", { + clientInformation: validClientInfo, + token: "access_token_123", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://auth.example.com/revoke", + }), + expect.any(Object) + ); + }); + + it("includes resource parameter when provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + await revokeToken("https://auth.example.com", { + clientInformation: validClientInfo, + token: "access_token_123", + resource: new URL("https://api.example.com/mcp-server"), + }); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("handles client without secret", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const clientWithoutSecret = { + client_id: "public_client", + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Public Client", + }; + + await revokeToken("https://auth.example.com", { + clientInformation: clientWithoutSecret, + token: "access_token_123", + }); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get("client_id")).toBe("public_client"); + expect(body.has("client_secret")).toBe(false); + }); + + it("throws on 500 Internal Server Error", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect( + revokeToken("https://auth.example.com", { + clientInformation: validClientInfo, + token: "access_token_123", + }) + ).rejects.toThrow("Token revocation failed: HTTP 500"); + }); + + it("throws on network error", async () => { + mockFetch.mockRejectedValueOnce(new TypeError("Network error")); + + await expect( + revokeToken("https://auth.example.com", { + clientInformation: validClientInfo, + token: "access_token_123", + }) + ).rejects.toThrow("Network error"); + }); + }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 71101a42..60cd2a72 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -593,6 +593,66 @@ export async function refreshAuthorization( return OAuthTokensSchema.parse({ refresh_token: refreshToken, ...(await response.json()) }); } +/** + * Revokes an OAuth 2.0 access token or refresh token according to RFC 7009. + * + * Makes a direct HTTP POST request to the authorization server's revocation endpoint. + * The endpoint is discovered from OAuth metadata or defaults to `/revoke`. + */ +export async function revokeToken( + authorizationServerUrl: string | URL, + { + metadata, + clientInformation, + token, + tokenTypeHint, + resource, + }: { + metadata?: OAuthMetadata; + clientInformation: OAuthClientInformation; + token: string; + tokenTypeHint?: 'access_token' | 'refresh_token'; + resource?: URL; + }, +): Promise { + let revokeUrl: URL; + if (metadata?.revocation_endpoint) { + revokeUrl = new URL(metadata.revocation_endpoint); + } else { + revokeUrl = new URL("/revoke", authorizationServerUrl); + } + + // Build revocation request + const params = new URLSearchParams({ + token, + client_id: clientInformation.client_id, + }); + + if (clientInformation.client_secret) { + params.set("client_secret", clientInformation.client_secret); + } + + if (tokenTypeHint) { + params.set("token_type_hint", tokenTypeHint); + } + + if (resource) { + params.set("resource", resource.href); + } + + const response = await fetch(revokeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params, + }); + + if (!response.ok) { + throw new Error(`Token revocation failed: HTTP ${response.status}`); + } +} + /** * Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. */