Skip to content

Commit 29f2fe4

Browse files
committed
Add revokeToken() function for client
Implement the revokeToken() function in the client library to allow clients to revoke tokens on the server.
1 parent 16ea277 commit 29f2fe4

File tree

2 files changed

+242
-0
lines changed

2 files changed

+242
-0
lines changed

src/client/auth.test.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
discoverOAuthProtectedResourceMetadata,
99
extractResourceMetadataUrl,
1010
auth,
11+
revokeToken,
1112
type OAuthClientProvider,
1213
} from "./auth.js";
1314
import {ServerError} from "../server/auth/errors.js";
@@ -2088,4 +2089,185 @@ describe("OAuth Authorization", () => {
20882089
});
20892090
});
20902091

2092+
2093+
describe("revokeToken", () => {
2094+
const validClientInfo = {
2095+
client_id: "client123",
2096+
client_secret: "secret123",
2097+
redirect_uris: ["http://localhost:3000/callback"],
2098+
client_name: "Test Client",
2099+
};
2100+
2101+
const validMetadata = {
2102+
issuer: "https://auth.example.com",
2103+
authorization_endpoint: "https://auth.example.com/authorize",
2104+
token_endpoint: "https://auth.example.com/token",
2105+
revocation_endpoint: "https://auth.example.com/revoke",
2106+
response_types_supported: ["code"],
2107+
code_challenge_methods_supported: ["S256"],
2108+
};
2109+
2110+
it("revokes access token successfully", async () => {
2111+
mockFetch.mockResolvedValueOnce({
2112+
ok: true,
2113+
status: 200,
2114+
});
2115+
2116+
await revokeToken("https://auth.example.com", {
2117+
clientInformation: validClientInfo,
2118+
token: "access_token_123",
2119+
});
2120+
2121+
expect(mockFetch).toHaveBeenCalledWith(
2122+
expect.objectContaining({
2123+
href: "https://auth.example.com/revoke",
2124+
}),
2125+
expect.objectContaining({
2126+
method: "POST",
2127+
headers: {
2128+
"Content-Type": "application/x-www-form-urlencoded",
2129+
},
2130+
})
2131+
);
2132+
2133+
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
2134+
expect(body.get("token")).toBe("access_token_123");
2135+
expect(body.get("client_id")).toBe("client123");
2136+
expect(body.get("client_secret")).toBe("secret123");
2137+
expect(body.has("token_type_hint")).toBe(false);
2138+
});
2139+
2140+
it("revokes refresh token successfully", async () => {
2141+
mockFetch.mockResolvedValueOnce({
2142+
ok: true,
2143+
status: 200,
2144+
});
2145+
2146+
await revokeToken("https://auth.example.com", {
2147+
clientInformation: validClientInfo,
2148+
token: "refresh_token_123",
2149+
tokenTypeHint: "refresh_token",
2150+
});
2151+
2152+
expect(mockFetch).toHaveBeenCalledWith(
2153+
expect.objectContaining({
2154+
href: "https://auth.example.com/revoke",
2155+
}),
2156+
expect.objectContaining({
2157+
method: "POST",
2158+
headers: {
2159+
"Content-Type": "application/x-www-form-urlencoded",
2160+
},
2161+
})
2162+
);
2163+
2164+
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
2165+
expect(body.get("token")).toBe("refresh_token_123");
2166+
expect(body.get("token_type_hint")).toBe("refresh_token");
2167+
expect(body.get("client_id")).toBe("client123");
2168+
expect(body.get("client_secret")).toBe("secret123");
2169+
});
2170+
2171+
it("uses revocation_endpoint from metadata when provided", async () => {
2172+
mockFetch.mockResolvedValueOnce({
2173+
ok: true,
2174+
status: 200,
2175+
});
2176+
2177+
await revokeToken("https://auth.example.com", {
2178+
metadata: validMetadata,
2179+
clientInformation: validClientInfo,
2180+
token: "access_token_123",
2181+
});
2182+
2183+
expect(mockFetch).toHaveBeenCalledWith(
2184+
expect.objectContaining({
2185+
href: "https://auth.example.com/revoke",
2186+
}),
2187+
expect.any(Object)
2188+
);
2189+
});
2190+
2191+
it("falls back to /revoke endpoint when metadata not provided", async () => {
2192+
mockFetch.mockResolvedValueOnce({
2193+
ok: true,
2194+
status: 200,
2195+
});
2196+
2197+
await revokeToken("https://auth.example.com", {
2198+
clientInformation: validClientInfo,
2199+
token: "access_token_123",
2200+
});
2201+
2202+
expect(mockFetch).toHaveBeenCalledWith(
2203+
expect.objectContaining({
2204+
href: "https://auth.example.com/revoke",
2205+
}),
2206+
expect.any(Object)
2207+
);
2208+
});
2209+
2210+
it("includes resource parameter when provided", async () => {
2211+
mockFetch.mockResolvedValueOnce({
2212+
ok: true,
2213+
status: 200,
2214+
});
2215+
2216+
await revokeToken("https://auth.example.com", {
2217+
clientInformation: validClientInfo,
2218+
token: "access_token_123",
2219+
resource: new URL("https://api.example.com/mcp-server"),
2220+
});
2221+
2222+
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
2223+
expect(body.get("resource")).toBe("https://api.example.com/mcp-server");
2224+
});
2225+
2226+
it("handles client without secret", async () => {
2227+
mockFetch.mockResolvedValueOnce({
2228+
ok: true,
2229+
status: 200,
2230+
});
2231+
2232+
const clientWithoutSecret = {
2233+
client_id: "public_client",
2234+
redirect_uris: ["http://localhost:3000/callback"],
2235+
client_name: "Public Client",
2236+
};
2237+
2238+
await revokeToken("https://auth.example.com", {
2239+
clientInformation: clientWithoutSecret,
2240+
token: "access_token_123",
2241+
});
2242+
2243+
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
2244+
expect(body.get("client_id")).toBe("public_client");
2245+
expect(body.has("client_secret")).toBe(false);
2246+
});
2247+
2248+
it("throws on 500 Internal Server Error", async () => {
2249+
mockFetch.mockResolvedValueOnce({
2250+
ok: false,
2251+
status: 500,
2252+
});
2253+
2254+
await expect(
2255+
revokeToken("https://auth.example.com", {
2256+
clientInformation: validClientInfo,
2257+
token: "access_token_123",
2258+
})
2259+
).rejects.toThrow("Token revocation failed: HTTP 500");
2260+
});
2261+
2262+
it("throws on network error", async () => {
2263+
mockFetch.mockRejectedValueOnce(new TypeError("Network error"));
2264+
2265+
await expect(
2266+
revokeToken("https://auth.example.com", {
2267+
clientInformation: validClientInfo,
2268+
token: "access_token_123",
2269+
})
2270+
).rejects.toThrow("Network error");
2271+
});
2272+
});
20912273
});

src/client/auth.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,66 @@ export async function refreshAuthorization(
875875
return OAuthTokensSchema.parse({ refresh_token: refreshToken, ...(await response.json()) });
876876
}
877877

878+
/**
879+
* Revokes an OAuth 2.0 access token or refresh token according to RFC 7009.
880+
*
881+
* Makes a direct HTTP POST request to the authorization server's revocation endpoint.
882+
* The endpoint is discovered from OAuth metadata or defaults to `/revoke`.
883+
*/
884+
export async function revokeToken(
885+
authorizationServerUrl: string | URL,
886+
{
887+
metadata,
888+
clientInformation,
889+
token,
890+
tokenTypeHint,
891+
resource,
892+
}: {
893+
metadata?: OAuthMetadata;
894+
clientInformation: OAuthClientInformation;
895+
token: string;
896+
tokenTypeHint?: 'access_token' | 'refresh_token';
897+
resource?: URL;
898+
},
899+
): Promise<void> {
900+
let revokeUrl: URL;
901+
if (metadata?.revocation_endpoint) {
902+
revokeUrl = new URL(metadata.revocation_endpoint);
903+
} else {
904+
revokeUrl = new URL("/revoke", authorizationServerUrl);
905+
}
906+
907+
// Build revocation request
908+
const params = new URLSearchParams({
909+
token,
910+
client_id: clientInformation.client_id,
911+
});
912+
913+
if (clientInformation.client_secret) {
914+
params.set("client_secret", clientInformation.client_secret);
915+
}
916+
917+
if (tokenTypeHint) {
918+
params.set("token_type_hint", tokenTypeHint);
919+
}
920+
921+
if (resource) {
922+
params.set("resource", resource.href);
923+
}
924+
925+
const response = await fetch(revokeUrl, {
926+
method: "POST",
927+
headers: {
928+
"Content-Type": "application/x-www-form-urlencoded",
929+
},
930+
body: params,
931+
});
932+
933+
if (!response.ok) {
934+
throw new Error(`Token revocation failed: HTTP ${response.status}`);
935+
}
936+
}
937+
878938
/**
879939
* Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591.
880940
*/

0 commit comments

Comments
 (0)