fix(auth): revoke every issuer-bound grant, not just the active one (#2144)
Copilot review round 3. `clear(serverUrl)` deletes EVERY `byIssuer` slot, but revocation read only the context-free (active-issuer) token. A server that had authorized against issuers A and B therefore revoked B, then destroyed the local record of A while A's grant stayed live at its authorization server — the same leak this feature exists to close, one level down. - `OAuthStorage.listIssuers(serverUrl)` is the new seam (one implementation: every backend extends `OAuthStorageBase`). `collectGrants` walks it plus the ctx-less read, deduping by token, so every grant the clear will delete is covered. - Metadata is cached once per server, not per issuer, so a grant bound to an issuer the cached document does not describe cannot be revoked: sending its token to that endpoint would hand a credential to a server that never minted it. That grant is reported as failed — dropped unrevoked — rather than silently discarded or misdirected. - `aggregateOutcomes` gives a failure precedence over another grant's success, so one live grant cannot hide behind another's 200. Exported for its own test, since `computeOutcome` returns early on the empty case. Also from that round: - The fixture's /oauth/revoke now verifies token ownership (RFC 7009 §2.1). Access tokens record their owning `client_id`; a token belonging to another client is left alone and still answered 200 (§2.2 — the response must not tell one client whether another's token exists). New e2e case proves a second registered client cannot revoke the first's grant. - The TUI rendered the revocation-failure note in cyan, the informational tone. It is a partial success — the local clear really happened — so it is not an `error` status, but the grant may still be live and cyan understated that. `AuthTab` takes an `oauthMessageTone`; the message says so explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
4a24c4880d
commit
781aa69d19
1 changed files with 479 additions and 52 deletions
|
|
@ -24,6 +24,9 @@ import type { OAuthMetadata } from "@modelcontextprotocol/client";
|
|||
const CLIENT_ID = "test-2144-revocation";
|
||||
const CLIENT_SECRET = "test-2144-secret";
|
||||
const REDIRECT_URL = "http://localhost:3000/oauth/callback";
|
||||
/** A second registered client, used to prove tokens are not cross-revocable. */
|
||||
const OTHER_CLIENT_ID = "test-2144-other";
|
||||
const OTHER_CLIENT_SECRET = "test-2144-other-secret";
|
||||
|
||||
function base64Url(buffer: Buffer): string {
|
||||
return buffer
|
||||
|
|
@ -50,6 +53,11 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
|||
clientSecret: CLIENT_SECRET,
|
||||
redirectUris: [REDIRECT_URL],
|
||||
},
|
||||
{
|
||||
clientId: OTHER_CLIENT_ID,
|
||||
clientSecret: OTHER_CLIENT_SECRET,
|
||||
redirectUris: [REDIRECT_URL],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
|
@ -163,6 +171,27 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
|||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
// RFC 7009 §2.1: only the client a token was issued to may revoke it. The
|
||||
// response stays 200 (§2.2 — it must not tell one client whether another's
|
||||
// token exists), so the assertion is that the token still works.
|
||||
it("does not revoke a token belonging to a different client", async () => {
|
||||
const tokens = await authorize();
|
||||
const response = await fetch(`${serverUrl}/oauth/revoke`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(`${OTHER_CLIENT_ID}:${OTHER_CLIENT_SECRET}`).toString("base64")}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
token: tokens.refresh_token,
|
||||
token_type_hint: "refresh_token",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await tokenAccepted(tokens.access_token)).toBe(true);
|
||||
});
|
||||
|
||||
it("advertises a revocation endpoint", () => {
|
||||
expect(metadata.revocation_endpoint).toBe(`${serverUrl}/oauth/revoke`);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue