fix(auth): clear before waiting on the network, not after (#2144)
Copilot review round 13. Three of its four findings were one root cause, and it is a design flaw in the premise this PR started from. Revoking *before* the clear meant the clear waited up to five seconds on the network — and anything that wrote OAuth state in that window was then deleted by a clear reasoning about the grant it replaced. A same-server reauthorization in the TUI (whose Disconnect/Connect accelerators stay live), a stored-only web clear for a server that becomes active mid-flight, and another CLI/TUI process writing to the shared file-backed store all hit it. `revokeStoredOAuthTokens` is therefore split into `planOAuthRevocation` and `executeOAuthRevocation`, and every caller runs plan → `storage.clear()` → execute. The snapshot still precedes the clear, because the store is where the token, the credentials and the endpoint live; but the clear no longer waits on anything. There is deliberately no single-call wrapper left, so no caller can reintroduce the racy order by accident. Fourth finding, same file: `handleConnect` now retires `clearOAuthAttemptRef` alongside `disconnectAttemptRef`. The server-name check does not cover disconnect/reconnect to the SAME server — the name still matches on the other side of it — so a clear still in flight could tear down the session the connect had just established. 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
c0bcdc7e4a
commit
69fcdd364c
3 changed files with 447 additions and 214 deletions
|
|
@ -6,11 +6,13 @@ import {
|
|||
aggregateOutcomes,
|
||||
buildRevocationRequest,
|
||||
revocationAuthMethods,
|
||||
revokeStoredOAuthTokens,
|
||||
executeOAuthRevocation,
|
||||
planOAuthRevocation,
|
||||
revokeToken,
|
||||
selectRevocableToken,
|
||||
} from "@inspector/core/auth/revocation.js";
|
||||
import type { InspectorLogger } from "@inspector/core/logging/index.js";
|
||||
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
|
||||
|
||||
/** A fully-typed `InspectorLogger` double, so the mock's shape is checked. */
|
||||
function fakeLogger(): InspectorLogger {
|
||||
|
|
@ -339,7 +341,32 @@ describe("revokeToken", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("revokeStoredOAuthTokens", () => {
|
||||
/**
|
||||
* Compose the two halves the way every caller does, minus the `storage.clear`
|
||||
* between them — these tests are about what is planned and sent, and the
|
||||
* ordering guarantee itself is asserted separately below.
|
||||
*/
|
||||
async function revokeStoredOAuthTokens(params: {
|
||||
serverUrl: string;
|
||||
storage: BrowserOAuthStorage;
|
||||
fetchFn: typeof fetch;
|
||||
enabled?: boolean;
|
||||
timeoutMs?: number;
|
||||
logger?: InspectorLogger;
|
||||
}): Promise<TokenRevocationOutcome> {
|
||||
const plan = await planOAuthRevocation({
|
||||
serverUrl: params.serverUrl,
|
||||
storage: params.storage,
|
||||
enabled: params.enabled,
|
||||
});
|
||||
return executeOAuthRevocation(plan, {
|
||||
fetchFn: params.fetchFn,
|
||||
timeoutMs: params.timeoutMs,
|
||||
logger: params.logger,
|
||||
});
|
||||
}
|
||||
|
||||
describe("revokeStoredOAuthTokens (plan + execute)", () => {
|
||||
let storage: BrowserOAuthStorage;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -890,3 +917,87 @@ describe("revokeStoredOAuthTokens", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The reason the API is two halves rather than one call (#2144, review round
|
||||
// 13). Revoking first and clearing afterwards delays the clear by however long
|
||||
// the network takes, and a FRESH authorization completing in that window is
|
||||
// then deleted by a clear reasoning about the grant it replaced.
|
||||
describe("plan / clear / execute ordering", () => {
|
||||
let storage: BrowserOAuthStorage;
|
||||
|
||||
beforeEach(async () => {
|
||||
storage = new BrowserOAuthStorage();
|
||||
await storage.clear(SERVER_URL);
|
||||
await storage.saveServerMetadata(
|
||||
SERVER_URL,
|
||||
metadata({ issuer: "https://as.example.com" }),
|
||||
);
|
||||
await storage.saveTokens(
|
||||
SERVER_URL,
|
||||
{ access_token: "old", token_type: "Bearer", refresh_token: "old-r" },
|
||||
{ issuer: "https://as.example.com" },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not delete a grant written while the request is in flight", async () => {
|
||||
let releaseRequest: () => void = () => {};
|
||||
const inFlight = new Promise<void>((resolve) => {
|
||||
releaseRequest = resolve;
|
||||
});
|
||||
const fetchFn = vi.fn<typeof fetch>(async () => {
|
||||
await inFlight;
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
|
||||
// The caller's real sequence: snapshot, clear, then send.
|
||||
const plan = await planOAuthRevocation({ serverUrl: SERVER_URL, storage });
|
||||
await storage.clear(SERVER_URL);
|
||||
const sending = executeOAuthRevocation(plan, { fetchFn });
|
||||
|
||||
// A fresh authorization lands while the request is still out.
|
||||
await storage.saveTokens(
|
||||
SERVER_URL,
|
||||
{ access_token: "new", token_type: "Bearer", refresh_token: "new-r" },
|
||||
{ issuer: "https://as.example.com" },
|
||||
);
|
||||
|
||||
releaseRequest();
|
||||
await sending;
|
||||
|
||||
// The new grant survived, and the OLD token is what was revoked.
|
||||
expect((await storage.getTokens(SERVER_URL))?.access_token).toBe("new");
|
||||
expect(
|
||||
new URLSearchParams(String(fetchFn.mock.calls[0]![1]!.body)).get("token"),
|
||||
).toBe("old-r");
|
||||
});
|
||||
|
||||
it("sends the snapshot even though the store was emptied first", async () => {
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 }),
|
||||
);
|
||||
|
||||
const plan = await planOAuthRevocation({ serverUrl: SERVER_URL, storage });
|
||||
await storage.clear(SERVER_URL);
|
||||
|
||||
await expect(
|
||||
executeOAuthRevocation(plan, { fetchFn }),
|
||||
).resolves.toMatchObject({ status: "revoked" });
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("plans nothing when revocation is disabled", async () => {
|
||||
const plan = await planOAuthRevocation({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
enabled: false,
|
||||
});
|
||||
const fetchFn = vi.fn<typeof fetch>();
|
||||
|
||||
expect(plan.grants).toHaveLength(0);
|
||||
await expect(executeOAuthRevocation(plan, { fetchFn })).resolves.toEqual({
|
||||
status: "skipped",
|
||||
reason: "disabled",
|
||||
});
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -199,10 +199,12 @@ describe("OAuthManager", () => {
|
|||
expect(manager.getOAuthFlowStep()).toBeUndefined();
|
||||
});
|
||||
|
||||
// #2144 — the ordering is the contract, not an implementation detail: the
|
||||
// revocation request is built from the token, the client id and the cached
|
||||
// metadata that `clear` is about to delete.
|
||||
it("revokes at the authorization server before clearing local state", async () => {
|
||||
// #2144 — the ordering is the contract, not an implementation detail. The
|
||||
// request is built from the token, the client id and the cached metadata
|
||||
// `clear` deletes, so the snapshot has to be taken first; but the clear
|
||||
// must then run BEFORE the network, or a fresh authorization completing
|
||||
// during it would be deleted by a clear reasoning about the old grant.
|
||||
it("clears local state before waiting on the revocation request", async () => {
|
||||
const params = createMockParams();
|
||||
const storage = params.initialConfig.storage!;
|
||||
vi.mocked(storage.getTokens).mockResolvedValue({
|
||||
|
|
@ -234,7 +236,7 @@ describe("OAuthManager", () => {
|
|||
status: "revoked",
|
||||
tokenTypeHint: "refresh_token",
|
||||
});
|
||||
expect(order).toEqual(["revoke", "clear"]);
|
||||
expect(order).toEqual(["clear", "revoke"]);
|
||||
});
|
||||
|
||||
it("clears local state even when the revocation request fails", async () => {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@ import {
|
|||
waitForOAuthWellKnown,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
|
||||
import { revokeStoredOAuthTokens } from "@inspector/core/auth/revocation.js";
|
||||
import {
|
||||
executeOAuthRevocation,
|
||||
planOAuthRevocation,
|
||||
} from "@inspector/core/auth/revocation.js";
|
||||
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
|
||||
import type { OAuthMetadata } from "@modelcontextprotocol/client";
|
||||
|
||||
const CLIENT_ID = "test-2144-revocation";
|
||||
|
|
@ -192,6 +196,18 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
|||
expect(await tokenAccepted(tokens.access_token)).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* The caller's real sequence — snapshot, clear, send — so this exercises the
|
||||
* ordering the product uses rather than a convenience wrapper.
|
||||
*/
|
||||
async function clearAndRevoke(
|
||||
storage: BrowserOAuthStorage,
|
||||
): Promise<TokenRevocationOutcome> {
|
||||
const plan = await planOAuthRevocation({ serverUrl, storage });
|
||||
await storage.clear(serverUrl);
|
||||
return executeOAuthRevocation(plan, { fetchFn: fetch });
|
||||
}
|
||||
|
||||
it("advertises a revocation endpoint", () => {
|
||||
expect(metadata.revocation_endpoint).toBe(`${serverUrl}/oauth/revoke`);
|
||||
});
|
||||
|
|
@ -202,11 +218,7 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
|||
const tokens = await authorize();
|
||||
expect(await tokenAccepted(tokens.access_token)).toBe(true);
|
||||
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl,
|
||||
storage: await seededStorage(tokens),
|
||||
fetchFn: fetch,
|
||||
});
|
||||
const outcome = await clearAndRevoke(await seededStorage(tokens));
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
status: "revoked",
|
||||
|
|
@ -219,11 +231,9 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
|||
|
||||
it("revokes an access token when no refresh token was issued", async () => {
|
||||
const tokens = await authorize();
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl,
|
||||
storage: await seededStorage({ access_token: tokens.access_token }),
|
||||
fetchFn: fetch,
|
||||
});
|
||||
const outcome = await clearAndRevoke(
|
||||
await seededStorage({ access_token: tokens.access_token }),
|
||||
);
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
status: "revoked",
|
||||
|
|
@ -236,8 +246,8 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
|||
// server has already expired must not be reported as a failure.
|
||||
it("treats an already-unknown token as revoked", async () => {
|
||||
const storage = await seededStorage({ access_token: "never-issued" });
|
||||
await expect(
|
||||
revokeStoredOAuthTokens({ serverUrl, storage, fetchFn: fetch }),
|
||||
).resolves.toMatchObject({ status: "revoked" });
|
||||
await expect(clearAndRevoke(storage)).resolves.toMatchObject({
|
||||
status: "revoked",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue