Skip to content

Commit c904362

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 1ac2e59 commit c904362

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

@@ -1477,4 +1478,185 @@ describe("OAuth Authorization", () => {
14771478
expect(body.get("refresh_token")).toBe("refresh123");
14781479
});
14791480
});
1481+
1482+
describe("revokeToken", () => {
1483+
const validClientInfo = {
1484+
client_id: "client123",
1485+
client_secret: "secret123",
1486+
redirect_uris: ["http://localhost:3000/callback"],
1487+
client_name: "Test Client",
1488+
};
1489+
1490+
const validMetadata = {
1491+
issuer: "https://auth.example.com",
1492+
authorization_endpoint: "https://auth.example.com/authorize",
1493+
token_endpoint: "https://auth.example.com/token",
1494+
revocation_endpoint: "https://auth.example.com/revoke",
1495+
response_types_supported: ["code"],
1496+
code_challenge_methods_supported: ["S256"],
1497+
};
1498+
1499+
it("revokes access token successfully", async () => {
1500+
mockFetch.mockResolvedValueOnce({
1501+
ok: true,
1502+
status: 200,
1503+
});
1504+
1505+
await revokeToken("https://auth.example.com", {
1506+
clientInformation: validClientInfo,
1507+
token: "access_token_123",
1508+
});
1509+
1510+
expect(mockFetch).toHaveBeenCalledWith(
1511+
expect.objectContaining({
1512+
href: "https://auth.example.com/revoke",
1513+
}),
1514+
expect.objectContaining({
1515+
method: "POST",
1516+
headers: {
1517+
"Content-Type": "application/x-www-form-urlencoded",
1518+
},
1519+
})
1520+
);
1521+
1522+
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
1523+
expect(body.get("token")).toBe("access_token_123");
1524+
expect(body.get("client_id")).toBe("client123");
1525+
expect(body.get("client_secret")).toBe("secret123");
1526+
expect(body.has("token_type_hint")).toBe(false);
1527+
});
1528+
1529+
it("revokes refresh token successfully", async () => {
1530+
mockFetch.mockResolvedValueOnce({
1531+
ok: true,
1532+
status: 200,
1533+
});
1534+
1535+
await revokeToken("https://auth.example.com", {
1536+
clientInformation: validClientInfo,
1537+
token: "refresh_token_123",
1538+
tokenTypeHint: "refresh_token",
1539+
});
1540+
1541+
expect(mockFetch).toHaveBeenCalledWith(
1542+
expect.objectContaining({
1543+
href: "https://auth.example.com/revoke",
1544+
}),
1545+
expect.objectContaining({
1546+
method: "POST",
1547+
headers: {
1548+
"Content-Type": "application/x-www-form-urlencoded",
1549+
},
1550+
})
1551+
);
1552+
1553+
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
1554+
expect(body.get("token")).toBe("refresh_token_123");
1555+
expect(body.get("token_type_hint")).toBe("refresh_token");
1556+
expect(body.get("client_id")).toBe("client123");
1557+
expect(body.get("client_secret")).toBe("secret123");
1558+
});
1559+
1560+
it("uses revocation_endpoint from metadata when provided", async () => {
1561+
mockFetch.mockResolvedValueOnce({
1562+
ok: true,
1563+
status: 200,
1564+
});
1565+
1566+
await revokeToken("https://auth.example.com", {
1567+
metadata: validMetadata,
1568+
clientInformation: validClientInfo,
1569+
token: "access_token_123",
1570+
});
1571+
1572+
expect(mockFetch).toHaveBeenCalledWith(
1573+
expect.objectContaining({
1574+
href: "https://auth.example.com/revoke",
1575+
}),
1576+
expect.any(Object)
1577+
);
1578+
});
1579+
1580+
it("falls back to /revoke endpoint when metadata not provided", async () => {
1581+
mockFetch.mockResolvedValueOnce({
1582+
ok: true,
1583+
status: 200,
1584+
});
1585+
1586+
await revokeToken("https://auth.example.com", {
1587+
clientInformation: validClientInfo,
1588+
token: "access_token_123",
1589+
});
1590+
1591+
expect(mockFetch).toHaveBeenCalledWith(
1592+
expect.objectContaining({
1593+
href: "https://auth.example.com/revoke",
1594+
}),
1595+
expect.any(Object)
1596+
);
1597+
});
1598+
1599+
it("includes resource parameter when provided", async () => {
1600+
mockFetch.mockResolvedValueOnce({
1601+
ok: true,
1602+
status: 200,
1603+
});
1604+
1605+
await revokeToken("https://auth.example.com", {
1606+
clientInformation: validClientInfo,
1607+
token: "access_token_123",
1608+
resource: new URL("https://api.example.com/mcp-server"),
1609+
});
1610+
1611+
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
1612+
expect(body.get("resource")).toBe("https://api.example.com/mcp-server");
1613+
});
1614+
1615+
it("handles client without secret", async () => {
1616+
mockFetch.mockResolvedValueOnce({
1617+
ok: true,
1618+
status: 200,
1619+
});
1620+
1621+
const clientWithoutSecret = {
1622+
client_id: "public_client",
1623+
redirect_uris: ["http://localhost:3000/callback"],
1624+
client_name: "Public Client",
1625+
};
1626+
1627+
await revokeToken("https://auth.example.com", {
1628+
clientInformation: clientWithoutSecret,
1629+
token: "access_token_123",
1630+
});
1631+
1632+
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
1633+
expect(body.get("client_id")).toBe("public_client");
1634+
expect(body.has("client_secret")).toBe(false);
1635+
});
1636+
1637+
it("throws on 500 Internal Server Error", async () => {
1638+
mockFetch.mockResolvedValueOnce({
1639+
ok: false,
1640+
status: 500,
1641+
});
1642+
1643+
await expect(
1644+
revokeToken("https://auth.example.com", {
1645+
clientInformation: validClientInfo,
1646+
token: "access_token_123",
1647+
})
1648+
).rejects.toThrow("Token revocation failed: HTTP 500");
1649+
});
1650+
1651+
it("throws on network error", async () => {
1652+
mockFetch.mockRejectedValueOnce(new TypeError("Network error"));
1653+
1654+
await expect(
1655+
revokeToken("https://auth.example.com", {
1656+
clientInformation: validClientInfo,
1657+
token: "access_token_123",
1658+
})
1659+
).rejects.toThrow("Network error");
1660+
});
1661+
});
14801662
});

src/client/auth.ts

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

596+
/**
597+
* Revokes an OAuth 2.0 access token or refresh token according to RFC 7009.
598+
*
599+
* Makes a direct HTTP POST request to the authorization server's revocation endpoint.
600+
* The endpoint is discovered from OAuth metadata or defaults to `/revoke`.
601+
*/
602+
export async function revokeToken(
603+
authorizationServerUrl: string | URL,
604+
{
605+
metadata,
606+
clientInformation,
607+
token,
608+
tokenTypeHint,
609+
resource,
610+
}: {
611+
metadata?: OAuthMetadata;
612+
clientInformation: OAuthClientInformation;
613+
token: string;
614+
tokenTypeHint?: 'access_token' | 'refresh_token';
615+
resource?: URL;
616+
},
617+
): Promise<void> {
618+
let revokeUrl: URL;
619+
if (metadata?.revocation_endpoint) {
620+
revokeUrl = new URL(metadata.revocation_endpoint);
621+
} else {
622+
revokeUrl = new URL("/revoke", authorizationServerUrl);
623+
}
624+
625+
// Build revocation request
626+
const params = new URLSearchParams({
627+
token,
628+
client_id: clientInformation.client_id,
629+
});
630+
631+
if (clientInformation.client_secret) {
632+
params.set("client_secret", clientInformation.client_secret);
633+
}
634+
635+
if (tokenTypeHint) {
636+
params.set("token_type_hint", tokenTypeHint);
637+
}
638+
639+
if (resource) {
640+
params.set("resource", resource.href);
641+
}
642+
643+
const response = await fetch(revokeUrl, {
644+
method: "POST",
645+
headers: {
646+
"Content-Type": "application/x-www-form-urlencoded",
647+
},
648+
body: params,
649+
});
650+
651+
if (!response.ok) {
652+
throw new Error(`Token revocation failed: HTTP ${response.status}`);
653+
}
654+
}
655+
596656
/**
597657
* Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591.
598658
*/

0 commit comments

Comments
 (0)