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
11 changed files with 479 additions and 52 deletions
|
|
@ -1407,9 +1407,18 @@ describe("App (mid-session auth lifecycle events)", () => {
|
|||
});
|
||||
const r = await mount(oneHttp());
|
||||
await press(r, ["a", "s"]);
|
||||
// The detail is what identifies the failure; the tone the message renders
|
||||
// in is asserted against `AuthTab` directly, where the branch lives.
|
||||
await expectFrame(r, "unreachable");
|
||||
});
|
||||
|
||||
it("says nothing when there was nothing to revoke", async () => {
|
||||
const r = await mount(oneHttp());
|
||||
await press(r, ["a", "s"]);
|
||||
await waitUntil(() => h.clientSpies.clearOAuthTokens.mock.calls.length > 0);
|
||||
expect(r.lastFrame() ?? "").not.toContain("authorization server failed");
|
||||
});
|
||||
|
||||
const stepUpChallenge = {
|
||||
reason: "insufficient_scope" as const,
|
||||
requiredScopes: ["env:read"],
|
||||
|
|
|
|||
|
|
@ -100,6 +100,35 @@ describe("AuthTab", () => {
|
|||
);
|
||||
});
|
||||
|
||||
// #2144 — a revocation failure is a *partial* success: the local state really
|
||||
// was cleared, so it is not an `error` status, but the grant may still be
|
||||
// live at the authorization server and the informational tone understates
|
||||
// that. Both tones are exercised so neither branch can rot.
|
||||
it("renders an idle message in the warning tone when asked", () => {
|
||||
const { lastFrame } = render(
|
||||
<AuthTab
|
||||
{...baseProps}
|
||||
inspectorClient={null}
|
||||
oauthStatus="idle"
|
||||
oauthMessage="Cleared locally, but revoking failed."
|
||||
oauthMessageTone="warning"
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame() ?? "").toContain("Cleared locally");
|
||||
});
|
||||
|
||||
it("renders an idle message in the default informational tone", () => {
|
||||
const { lastFrame } = render(
|
||||
<AuthTab
|
||||
{...baseProps}
|
||||
inspectorClient={null}
|
||||
oauthStatus="idle"
|
||||
oauthMessage="Stored OAuth state cleared."
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame() ?? "").toContain("Stored OAuth state cleared.");
|
||||
});
|
||||
|
||||
it("renders OAuth details from getOAuthState", async () => {
|
||||
const { client } = makeClient(sampleOAuthState);
|
||||
const { lastFrame } = render(
|
||||
|
|
|
|||
|
|
@ -161,6 +161,13 @@ function App({
|
|||
"idle" | "authenticating" | "error"
|
||||
>("idle");
|
||||
const [oauthMessage, setOauthMessage] = useState<string | null>(null);
|
||||
// #2144: a revocation failure is a partial success — the local state really
|
||||
// was cleared — so it is not an `error` status, but it must not read as an
|
||||
// ordinary note either: the grant may still be live at the authorization
|
||||
// server.
|
||||
const [oauthMessageTone, setOauthMessageTone] = useState<"info" | "warning">(
|
||||
"info",
|
||||
);
|
||||
const [oauthRevision, setOauthRevision] = useState(0);
|
||||
const [pendingStepUp, setPendingStepUp] = useState<{
|
||||
serverName: string;
|
||||
|
|
@ -949,9 +956,10 @@ function App({
|
|||
revoke: selectedServerEntry?.settings?.oauthRevokeOnClear !== false,
|
||||
});
|
||||
setOauthStatus("idle");
|
||||
setOauthMessageTone(revocation.status === "failed" ? "warning" : "info");
|
||||
setOauthMessage(
|
||||
revocation.status === "failed"
|
||||
? `Cleared locally, but revoking the grant at the authorization server failed: ${revocation.detail}`
|
||||
? `Cleared locally, but revoking the grant at the authorization server failed: ${revocation.detail}. It may still be valid there.`
|
||||
: null,
|
||||
);
|
||||
setConnectError(null);
|
||||
|
|
@ -1756,6 +1764,7 @@ function App({
|
|||
inspectorClient={selectedInspectorClient}
|
||||
oauthStatus={oauthStatus}
|
||||
oauthMessage={oauthMessage}
|
||||
oauthMessageTone={oauthMessageTone}
|
||||
oauthRevision={oauthRevision}
|
||||
pendingStepUp={
|
||||
pendingStepUp?.serverName === selectedServer
|
||||
|
|
|
|||
|
|
@ -28,6 +28,14 @@ interface AuthTabProps {
|
|||
inspectorClient: InspectorClient | null;
|
||||
oauthStatus: "idle" | "authenticating" | "error";
|
||||
oauthMessage: string | null;
|
||||
/**
|
||||
* How to colour {@link oauthMessage} on the `idle` status. Defaults to
|
||||
* `"info"` (cyan). `"warning"` exists for the one message that reports a
|
||||
* *partial* success: the OAuth state really was cleared, so this is not an
|
||||
* error, but the grant may still be live at the authorization server and
|
||||
* cyan would understate that (#2144).
|
||||
*/
|
||||
oauthMessageTone?: "info" | "warning";
|
||||
oauthRevision: number;
|
||||
pendingStepUp?: {
|
||||
challenge: AuthChallenge;
|
||||
|
|
@ -57,6 +65,7 @@ export function AuthTab({
|
|||
inspectorClient,
|
||||
oauthStatus,
|
||||
oauthMessage,
|
||||
oauthMessageTone = "info",
|
||||
oauthRevision,
|
||||
pendingStepUp,
|
||||
onAuthorizeStepUp,
|
||||
|
|
@ -190,7 +199,9 @@ export function AuthTab({
|
|||
<Text color="red">{oauthMessage}</Text>
|
||||
)}
|
||||
{oauthStatus === "idle" && oauthMessage && (
|
||||
<Text color="cyan">{oauthMessage}</Text>
|
||||
<Text color={oauthMessageTone === "warning" ? "yellow" : "cyan"}>
|
||||
{oauthMessage}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{pendingStepUp ? (
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ function createStorage(
|
|||
clearServerMetadata: vi.fn(),
|
||||
clearIdpSession: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
listIssuers: vi.fn().mockResolvedValue([]),
|
||||
getCodeVerifier: vi.fn(),
|
||||
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
saveDiscoveryState: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ function createMemoryStorage(
|
|||
saveScope: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
listIssuers: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as OAuthStorage;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ describe("idpSession", () => {
|
|||
clearIdpSession: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
listIssuers: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as OAuthStorage;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { OAuthMetadata } from "@modelcontextprotocol/client";
|
|||
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
|
||||
import {
|
||||
DEFAULT_REVOCATION_TIMEOUT_MS,
|
||||
aggregateOutcomes,
|
||||
buildRevocationRequest,
|
||||
revocationAuthMethods,
|
||||
revokeStoredOAuthTokens,
|
||||
|
|
@ -131,6 +132,40 @@ describe("revocationAuthMethods", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("aggregateOutcomes", () => {
|
||||
const failed = { status: "failed", detail: "boom" } as const;
|
||||
const revoked = {
|
||||
status: "revoked",
|
||||
tokenTypeHint: "refresh_token",
|
||||
endpoint: REVOKE_URL,
|
||||
} as const;
|
||||
const skipped = { status: "skipped", reason: "no_endpoint" } as const;
|
||||
|
||||
// A grant still live at the authorization server is the thing worth
|
||||
// surfacing; another grant's success would silence it.
|
||||
it("reports a failure over a success", () => {
|
||||
expect(aggregateOutcomes([revoked, failed])).toEqual(failed);
|
||||
});
|
||||
|
||||
it("reports a success over a skip", () => {
|
||||
expect(aggregateOutcomes([skipped, revoked])).toEqual(revoked);
|
||||
});
|
||||
|
||||
it("falls through to the first outcome when none is decisive", () => {
|
||||
expect(aggregateOutcomes([skipped])).toEqual(skipped);
|
||||
});
|
||||
|
||||
// `computeOutcome` returns early when there are no grants, so nothing else
|
||||
// reaches this — which is why it is tested here rather than left as a
|
||||
// function that returns `undefined` while typed otherwise.
|
||||
it("has an answer for an empty list", () => {
|
||||
expect(aggregateOutcomes([])).toEqual({
|
||||
status: "skipped",
|
||||
reason: "no_tokens",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRevocationRequest", () => {
|
||||
it("posts a form-encoded token and hint", () => {
|
||||
const { url, init } = buildRevocationRequest({
|
||||
|
|
@ -229,6 +264,23 @@ describe("revokeToken", () => {
|
|||
expect(outcome.status === "failed" ? outcome.detail : "").toContain("401");
|
||||
});
|
||||
|
||||
// `String(err)` is the other half of the detail: a fetch double, or a runtime
|
||||
// that rejects with a non-Error, must still produce a readable message.
|
||||
it("reports a non-Error rejection as failed", async () => {
|
||||
const outcome = await revokeToken({
|
||||
endpoint: REVOKE_URL,
|
||||
token: "r",
|
||||
tokenTypeHint: "refresh_token",
|
||||
supportedAuthMethods: [],
|
||||
fetchFn: () => Promise.reject("plain string"),
|
||||
});
|
||||
expect(outcome).toEqual({
|
||||
status: "failed",
|
||||
endpoint: REVOKE_URL,
|
||||
detail: "plain string",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a network failure as failed", async () => {
|
||||
const fetchFn = vi.fn<typeof fetch>(async () => {
|
||||
throw new Error("connect ECONNREFUSED");
|
||||
|
|
@ -351,6 +403,102 @@ describe("revokeStoredOAuthTokens", () => {
|
|||
);
|
||||
});
|
||||
|
||||
// `clear(serverUrl)` drops EVERY `byIssuer` slot, so reading only the active
|
||||
// issuer's token would leave the earlier authorization server's grant live
|
||||
// while destroying the local record of it — the same leak this feature
|
||||
// closes, one level down (SEP-2352 keeps credentials per issuer).
|
||||
it("revokes every issuer-bound grant, not just the active one", async () => {
|
||||
const issuerMetadata = metadata({ issuer: "https://as.example.com" });
|
||||
await storage.saveServerMetadata(SERVER_URL, issuerMetadata);
|
||||
await storage.saveTokens(
|
||||
SERVER_URL,
|
||||
{ access_token: "a1", token_type: "Bearer", refresh_token: "r1" },
|
||||
{ issuer: "https://as.example.com" },
|
||||
);
|
||||
await storage.saveTokens(
|
||||
SERVER_URL,
|
||||
{ access_token: "a2", token_type: "Bearer", refresh_token: "r2" },
|
||||
{ issuer: "https://as.example.com" },
|
||||
);
|
||||
// A second slot under the SAME issuer would be one grant; use two distinct
|
||||
// tokens under one issuer plus the ctx-less read to prove dedup instead.
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 }),
|
||||
);
|
||||
|
||||
await revokeStoredOAuthTokens({ serverUrl: SERVER_URL, storage, fetchFn });
|
||||
|
||||
// One issuer, one grant — the ctx-less read is the same token and is deduped.
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
new URLSearchParams(String(fetchFn.mock.calls[0]![1]!.body)).get("token"),
|
||||
).toBe("r2");
|
||||
});
|
||||
|
||||
// A grant bound to an issuer the cached metadata does not describe cannot be
|
||||
// revoked — that endpoint belongs to a different authorization server, and
|
||||
// sending it another AS's token would hand a credential to a server that
|
||||
// never minted it. Saying so is the point: the grant is being dropped.
|
||||
it("reports a grant whose issuer the cached metadata does not describe", async () => {
|
||||
await storage.saveServerMetadata(
|
||||
SERVER_URL,
|
||||
metadata({ issuer: "https://as-b.example.com" }),
|
||||
);
|
||||
await storage.saveTokens(
|
||||
SERVER_URL,
|
||||
{ access_token: "a", token_type: "Bearer", refresh_token: "r-a" },
|
||||
{ issuer: "https://as-a.example.com" },
|
||||
);
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 }),
|
||||
);
|
||||
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome.status === "failed" ? outcome.detail : "").toContain(
|
||||
"as-a.example.com",
|
||||
);
|
||||
});
|
||||
|
||||
// A failure on one grant must not be hidden behind another's success — a
|
||||
// grant still live at the authorization server is the thing worth surfacing.
|
||||
it("reports a failure over another grant's success", async () => {
|
||||
await storage.saveServerMetadata(
|
||||
SERVER_URL,
|
||||
metadata({ issuer: "https://as-b.example.com" }),
|
||||
);
|
||||
// Revocable: bound to the issuer the metadata describes.
|
||||
await storage.saveTokens(
|
||||
SERVER_URL,
|
||||
{ access_token: "b", token_type: "Bearer", refresh_token: "r-b" },
|
||||
{ issuer: "https://as-b.example.com" },
|
||||
);
|
||||
// Not revocable: bound to an issuer the cached metadata is not for.
|
||||
await storage.saveTokens(
|
||||
SERVER_URL,
|
||||
{ access_token: "a", token_type: "Bearer", refresh_token: "r-a" },
|
||||
{ issuer: "https://as-a.example.com" },
|
||||
);
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 }),
|
||||
);
|
||||
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
});
|
||||
|
||||
// The whole point of keeping this path opt-out-able: an authorization server
|
||||
// with no RFC 7009 support must behave exactly as it did before the feature.
|
||||
it("does nothing when the authorization server advertises no revocation endpoint", async () => {
|
||||
|
|
@ -430,6 +578,18 @@ describe("revokeStoredOAuthTokens", () => {
|
|||
// asked for, so the read is inside the try. Spying on the real instance keeps
|
||||
// the `OAuthStorage` contract intact — a spread-and-cast stand-in would type
|
||||
// as storage while being a plain object with none of its methods.
|
||||
it("reports a non-Error store failure as failed", async () => {
|
||||
vi.spyOn(storage, "listIssuers").mockRejectedValue("store exploded");
|
||||
|
||||
await expect(
|
||||
revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
fetchFn: vi.fn<typeof fetch>(),
|
||||
}),
|
||||
).resolves.toEqual({ status: "failed", detail: "store exploded" });
|
||||
});
|
||||
|
||||
it("reports a store read failure as failed", async () => {
|
||||
vi.spyOn(storage, "getTokens").mockRejectedValue(
|
||||
new Error("store unreadable"),
|
||||
|
|
|
|||
|
|
@ -358,6 +358,44 @@ describe("BrowserOAuthStorage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// #2144 — `clear` deletes every issuer slot, so anything that must act on the
|
||||
// credentials first (RFC 7009 revocation) needs to see them all.
|
||||
describe("listIssuers", () => {
|
||||
it("returns every issuer holding credentials for the server", async () => {
|
||||
await storage.saveTokens(
|
||||
testServerUrl,
|
||||
{ access_token: "a", token_type: "Bearer" },
|
||||
{ issuer: "https://as-a.example.com" },
|
||||
);
|
||||
await storage.saveTokens(
|
||||
testServerUrl,
|
||||
{ access_token: "b", token_type: "Bearer" },
|
||||
{ issuer: "https://as-b.example.com" },
|
||||
);
|
||||
|
||||
expect((await storage.listIssuers(testServerUrl)).sort()).toEqual([
|
||||
"https://as-a.example.com",
|
||||
"https://as-b.example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
// A pre-SEP-2352 entry has its credentials in the legacy unkeyed slot,
|
||||
// which the context-free reads answer — there is no issuer to list.
|
||||
it("is empty for an entry with no issuer-bound credentials", async () => {
|
||||
await storage.saveTokens(testServerUrl, {
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
expect(await storage.listIssuers(testServerUrl)).toEqual([]);
|
||||
});
|
||||
|
||||
it("is empty for a server with no state at all", async () => {
|
||||
expect(await storage.listIssuers("https://unknown.example/mcp")).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearServerState", () => {
|
||||
it("should clear all state for a server", async () => {
|
||||
const clientInfo: OAuthClientInformation = {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ function createMockParams(
|
|||
saveIdpSession: vi.fn().mockResolvedValue(undefined),
|
||||
clearIdpSession: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
listIssuers: vi.fn().mockResolvedValue([]),
|
||||
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
saveDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
clearDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
|
|||
|
|
@ -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