|
8 | 8 | discoverOAuthProtectedResourceMetadata,
|
9 | 9 | extractResourceMetadataUrl,
|
10 | 10 | auth,
|
| 11 | + revokeToken, |
11 | 12 | type OAuthClientProvider,
|
12 | 13 | } from "./auth.js";
|
13 | 14 | import {ServerError} from "../server/auth/errors.js";
|
@@ -2088,4 +2089,185 @@ describe("OAuth Authorization", () => {
|
2088 | 2089 | });
|
2089 | 2090 | });
|
2090 | 2091 |
|
| 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 | + }); |
2091 | 2273 | });
|
0 commit comments