fix(auth): make snapshot-and-clear one atomic storage step (#2144)
Copilot review round 18. Three of its findings were the same check-then-act: `planOAuthRevocation` is asynchronous, so the gap between its reads and the following `storage.clear()` is still a window — shorter than the network one closed in round 13, but the same shape. An OAuth completion landing at any of those awaits saved a grant the clear then destroyed. `OAuthStorage.takeRevocationSnapshot(serverUrl)` reads what revocation needs and clears the server in ONE synchronous pass over the in-memory state, with the persist after the mutation. `clearAndPlanRevocation` replaces `planOAuthRevocation`, and there is no separate `clear` call left in any caller — the clear cannot be forgotten or reordered. The snapshot returns values unparsed, deliberately: schema validation is pure and belongs after the mutation, since running it inside would reintroduce the `await` this exists to remove. Two consequences worth naming. A failure of the take-and-clear now REJECTS rather than returning an outcome, because the state was not cleared and the caller's contract is that it was; both clients already have a rejection path. And cross-process atomicity is explicitly not claimed — nothing in this store takes a lock, every mutation is a read-modify-write over a loaded snapshot, and `clear` alone always had that property. Also from round 18: - The CLI's budget check ran before considering `plan.outcome`, so a key that needed no network was reported as budget-exhausted — warning that a grant may be live when that key held none. - A disconnect failure after a successful clear was reported as "could not clear OAuth state" in both clients. It now goes to the disconnect channel, and the success is still reported. 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
0ded02b34c
commit
463227c947
7 changed files with 586 additions and 347 deletions
|
|
@ -59,6 +59,7 @@ function createStorage(
|
|||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
listIssuers: vi.fn().mockResolvedValue([]),
|
||||
getIssuerTokens: vi.fn().mockResolvedValue(undefined),
|
||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||
getCodeVerifier: vi.fn(),
|
||||
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
saveDiscoveryState: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ function createMemoryStorage(
|
|||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
listIssuers: vi.fn().mockResolvedValue([]),
|
||||
getIssuerTokens: vi.fn().mockResolvedValue(undefined),
|
||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||
} as unknown as OAuthStorage;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ describe("idpSession", () => {
|
|||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
listIssuers: vi.fn().mockResolvedValue([]),
|
||||
getIssuerTokens: vi.fn().mockResolvedValue(undefined),
|
||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||
} as unknown as OAuthStorage;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import {
|
|||
aggregateOutcomes,
|
||||
buildRevocationRequest,
|
||||
revocationAuthMethods,
|
||||
clearAndPlanRevocation,
|
||||
executeOAuthRevocation,
|
||||
planOAuthRevocation,
|
||||
revokeToken,
|
||||
selectRevocableToken,
|
||||
} from "@inspector/core/auth/revocation.js";
|
||||
|
|
@ -363,8 +363,8 @@ describe("revokeToken", () => {
|
|||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Compose the two halves the way every caller does. The first one clears, so a
|
||||
* test that asserts on the store afterwards is looking at an emptied one — the
|
||||
* ordering guarantee itself is asserted separately below.
|
||||
*/
|
||||
async function revokeStoredOAuthTokens(params: {
|
||||
|
|
@ -375,7 +375,7 @@ async function revokeStoredOAuthTokens(params: {
|
|||
timeoutMs?: number;
|
||||
logger?: InspectorLogger;
|
||||
}): Promise<TokenRevocationOutcome> {
|
||||
const plan = await planOAuthRevocation({
|
||||
const plan = await clearAndPlanRevocation({
|
||||
serverUrl: params.serverUrl,
|
||||
storage: params.storage,
|
||||
enabled: params.enabled,
|
||||
|
|
@ -387,6 +387,43 @@ async function revokeStoredOAuthTokens(params: {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the atomic take-and-clear with a hand-built snapshot. Needed wherever
|
||||
* the shape under test cannot be produced through the store's own API — a
|
||||
* legacy slot alongside an issuer slot, for instance, since an issuer-stamped
|
||||
* save promotes and clears the legacy one.
|
||||
*/
|
||||
function stubSnapshot(
|
||||
storage: BrowserOAuthStorage,
|
||||
snapshot: Partial<Parameters<typeof stubSnapshotShape>[0]> = {},
|
||||
): void {
|
||||
vi.spyOn(storage, "takeRevocationSnapshot").mockResolvedValue(
|
||||
stubSnapshotShape(snapshot),
|
||||
);
|
||||
}
|
||||
|
||||
function stubSnapshotShape(snapshot: {
|
||||
byIssuer?: Record<
|
||||
string,
|
||||
{ tokens?: unknown; clientInformation?: unknown } | undefined
|
||||
>;
|
||||
legacyTokens?: unknown;
|
||||
legacyClientInformation?: unknown;
|
||||
preregisteredClientInformation?: unknown;
|
||||
serverMetadata?: unknown;
|
||||
}): {
|
||||
byIssuer: Record<
|
||||
string,
|
||||
{ tokens?: unknown; clientInformation?: unknown } | undefined
|
||||
>;
|
||||
legacyTokens?: unknown;
|
||||
legacyClientInformation?: unknown;
|
||||
preregisteredClientInformation?: unknown;
|
||||
serverMetadata?: unknown;
|
||||
} {
|
||||
return { byIssuer: {}, ...snapshot };
|
||||
}
|
||||
|
||||
describe("revokeStoredOAuthTokens (plan + execute)", () => {
|
||||
let storage: BrowserOAuthStorage;
|
||||
|
||||
|
|
@ -568,28 +605,30 @@ describe("revokeStoredOAuthTokens (plan + execute)", () => {
|
|||
// Suppressing on the value would drop it unrevoked and unreported, which is
|
||||
// why the suppression keys off the store's issuer stamp instead.
|
||||
it("keeps the legacy grant when another issuer holds the same token value", async () => {
|
||||
// The metadata names the same issuer as the slot below, so BOTH grants are
|
||||
// genuinely revocable — otherwise this would pass for the wrong reason.
|
||||
await storage.saveServerMetadata(
|
||||
SERVER_URL,
|
||||
metadata({ issuer: "https://as-a.example.com" }),
|
||||
);
|
||||
// A legacy unkeyed grant...
|
||||
await storage.saveTokens(SERVER_URL, {
|
||||
access_token: "shared",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "shared-r",
|
||||
});
|
||||
// ...and an issuer slot that coincidentally holds the same token value.
|
||||
// Saved via the client-information path so it does not clear the legacy
|
||||
// slot, then given tokens through a stubbed exact read.
|
||||
vi.spyOn(storage, "listIssuers").mockResolvedValue([
|
||||
"https://as-a.example.com",
|
||||
]);
|
||||
vi.spyOn(storage, "getIssuerTokens").mockResolvedValue({
|
||||
access_token: "shared",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "shared-r",
|
||||
// Hand-built: an issuer-stamped save promotes and clears the legacy slot,
|
||||
// so the store's own API cannot produce both at once.
|
||||
stubSnapshot(storage, {
|
||||
byIssuer: {
|
||||
"https://as-a.example.com": {
|
||||
tokens: {
|
||||
access_token: "shared",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "shared-r",
|
||||
},
|
||||
},
|
||||
},
|
||||
legacyTokens: {
|
||||
access_token: "shared",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "shared-r",
|
||||
},
|
||||
serverMetadata: {
|
||||
issuer: "https://as-a.example.com",
|
||||
authorization_endpoint: "https://as-a.example.com/authorize",
|
||||
token_endpoint: "https://as-a.example.com/token",
|
||||
revocation_endpoint: REVOKE_URL,
|
||||
response_types_supported: ["code"],
|
||||
},
|
||||
});
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 }),
|
||||
|
|
@ -601,10 +640,9 @@ describe("revokeStoredOAuthTokens (plan + execute)", () => {
|
|||
fetchFn,
|
||||
});
|
||||
|
||||
// Still two GRANTS — that is what this is about. The issuer-bound one is
|
||||
// revoked; the legacy one is refused (unknown authorization server) and
|
||||
// reported, rather than being silently collapsed into the other by a
|
||||
// token-value dedup.
|
||||
// Two GRANTS despite one token value. The issuer-bound one is revoked; the
|
||||
// legacy one is refused (unknown authorization server) and reported, rather
|
||||
// than being silently collapsed into the other by a token-value dedup.
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome.status === "failed" ? outcome.detail : "").toContain(
|
||||
|
|
@ -672,35 +710,22 @@ describe("revokeStoredOAuthTokens (plan + execute)", () => {
|
|||
);
|
||||
});
|
||||
|
||||
// If `listIssuers` fails there is no enumerated slot behind the stamp, so
|
||||
// skipping a stamped ctx-less read would leave the one grant the store can
|
||||
// still produce unrevoked — while `clear` deleted it anyway.
|
||||
it("revokes the active grant even when the issuer list cannot be read", async () => {
|
||||
await storage.saveServerMetadata(
|
||||
SERVER_URL,
|
||||
metadata({ issuer: "https://as-a.example.com" }),
|
||||
);
|
||||
await storage.saveTokens(
|
||||
SERVER_URL,
|
||||
{ access_token: "a", token_type: "Bearer", refresh_token: "r-active" },
|
||||
{ issuer: "https://as-a.example.com" },
|
||||
);
|
||||
vi.spyOn(storage, "listIssuers").mockRejectedValue(new Error("no list"));
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 }),
|
||||
// The take-and-clear is one step, so its failure means the state is still
|
||||
// there. That has to REJECT rather than report: every caller's contract is
|
||||
// "the clear happened", and a `failed` outcome would say the opposite of what
|
||||
// occurred. Both clients have a rejection path for exactly this.
|
||||
it("rejects when the atomic take-and-clear itself fails", async () => {
|
||||
vi.spyOn(storage, "takeRevocationSnapshot").mockRejectedValue(
|
||||
new Error("store unwritable"),
|
||||
);
|
||||
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(
|
||||
new URLSearchParams(String(fetchFn.mock.calls[0]![1]!.body)).get("token"),
|
||||
).toBe("r-active");
|
||||
// The listing failure is still surfaced — it outranks the success.
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
await expect(
|
||||
revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
fetchFn: vi.fn<typeof fetch>(),
|
||||
}),
|
||||
).rejects.toThrow("store unwritable");
|
||||
});
|
||||
|
||||
// The timeout is a budget for the WHOLE teardown, not per request. `clear`
|
||||
|
|
@ -708,32 +733,35 @@ describe("revokeStoredOAuthTokens (plan + execute)", () => {
|
|||
// N of them block the disconnect for N × the timeout — at which point the
|
||||
// "short timeout" bounds a single request and nothing the user feels.
|
||||
it("shares one deadline across grants instead of one per grant", async () => {
|
||||
await storage.saveServerMetadata(SERVER_URL, metadata({ issuer: "a" }));
|
||||
// All three bound to the issuer the cached metadata describes, so all three
|
||||
// are genuinely revocable and the budget is what stops them.
|
||||
vi.spyOn(storage, "listIssuers").mockResolvedValue(["a", "b", "c"]);
|
||||
vi.spyOn(storage, "getIssuerTokens").mockImplementation(
|
||||
async (_url: string, issuer: string) => ({
|
||||
access_token: `a-${issuer}`,
|
||||
token_type: "Bearer",
|
||||
refresh_token: `r-${issuer}`,
|
||||
}),
|
||||
);
|
||||
// Every request hangs, so each one burns the whole remaining budget.
|
||||
// Built directly rather than through the store: three *revocable* grants
|
||||
// means three tokens under one issuer, which `byIssuer` cannot hold (one
|
||||
// slot per issuer). The budget logic is what is under test here, and a plan
|
||||
// is a plain value.
|
||||
const grant = (n: string) => ({
|
||||
issuer: "https://as.example.com",
|
||||
token: `r-${n}`,
|
||||
tokenTypeHint: "refresh_token" as const,
|
||||
});
|
||||
// Every request hangs, so the first burns the whole budget.
|
||||
const fetchFn = vi.fn<typeof fetch>(() => new Promise<Response>(() => {}));
|
||||
|
||||
const started = Date.now();
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
fetchFn,
|
||||
timeoutMs: 30,
|
||||
});
|
||||
const outcome = await executeOAuthRevocation(
|
||||
{
|
||||
serverUrl: SERVER_URL,
|
||||
grants: [grant("a"), grant("b"), grant("c")],
|
||||
failures: [],
|
||||
endpoint: REVOKE_URL,
|
||||
supportedAuthMethods: [],
|
||||
metadataIssuer: "https://as.example.com",
|
||||
},
|
||||
{ fetchFn, timeoutMs: 30 },
|
||||
);
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
// Three grants: with a per-grant bound this would be ~90ms. Generous upper
|
||||
// bound so the assertion is about the shape, not the machine.
|
||||
// With a per-grant bound this would be ~90ms. Generous upper bound so the
|
||||
// assertion is about the shape, not the machine.
|
||||
expect(elapsed).toBeLessThan(70);
|
||||
// The first burned the budget; the rest are reported as never attempted.
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -878,12 +906,23 @@ describe("revokeStoredOAuthTokens (plan + execute)", () => {
|
|||
expect(logger.debug).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A store that cannot be read is not a reason to abandon the clear the user
|
||||
// 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");
|
||||
// A snapshot the code cannot interpret is reported, not thrown: the state is
|
||||
// already gone by then, so there is nothing left for the caller to retry.
|
||||
// (A failure of the take-and-clear ITSELF rejects instead — see above.)
|
||||
it("reports an uninterpretable snapshot as failed", async () => {
|
||||
stubSnapshot(storage, {
|
||||
byIssuer: {
|
||||
// Not a valid `OAuthTokens` — no `token_type`.
|
||||
"https://as.example.com": { tokens: { access_token: 42 } },
|
||||
},
|
||||
serverMetadata: {
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: "https://as.example.com/authorize",
|
||||
token_endpoint: "https://as.example.com/token",
|
||||
revocation_endpoint: REVOKE_URL,
|
||||
response_types_supported: ["code"],
|
||||
},
|
||||
});
|
||||
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
|
|
@ -891,57 +930,32 @@ describe("revokeStoredOAuthTokens (plan + execute)", () => {
|
|||
fetchFn: vi.fn<typeof fetch>(),
|
||||
});
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome.status === "failed" ? outcome.detail : "").toContain(
|
||||
"store exploded",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a store read failure as failed", async () => {
|
||||
vi.spyOn(storage, "getTokens").mockRejectedValue(
|
||||
new Error("store unreadable"),
|
||||
);
|
||||
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
fetchFn: vi.fn<typeof fetch>(),
|
||||
});
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome.status === "failed" ? outcome.detail : "").toContain(
|
||||
"store unreadable",
|
||||
);
|
||||
});
|
||||
|
||||
// A corrupt slot must not abandon the grants that are still revocable — the
|
||||
// clear deletes them all either way, so the failure has to be reported
|
||||
// BESIDE the successes rather than instead of them.
|
||||
it("still revokes the readable grants when one issuer slot cannot be read", async () => {
|
||||
await storage.saveServerMetadata(
|
||||
SERVER_URL,
|
||||
metadata({ issuer: "https://as.example.com" }),
|
||||
);
|
||||
await storage.saveTokens(
|
||||
SERVER_URL,
|
||||
{ access_token: "a", token_type: "Bearer", refresh_token: "r-good" },
|
||||
{ issuer: "https://as.example.com" },
|
||||
);
|
||||
// A second issuer whose exact read throws.
|
||||
vi.spyOn(storage, "listIssuers").mockResolvedValue([
|
||||
"https://as.example.com",
|
||||
"https://broken.example.com",
|
||||
]);
|
||||
vi.spyOn(storage, "getIssuerTokens").mockImplementation(
|
||||
async (_url: string, issuer: string) => {
|
||||
if (issuer === "https://broken.example.com") {
|
||||
throw new Error("corrupt slot");
|
||||
}
|
||||
return {
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "r-good",
|
||||
};
|
||||
// state is gone either way, so the failure has to be reported BESIDE the
|
||||
// successes rather than instead of them.
|
||||
it("still revokes the readable grants when one slot cannot be parsed", async () => {
|
||||
stubSnapshot(storage, {
|
||||
byIssuer: {
|
||||
"https://as.example.com": {
|
||||
tokens: {
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "r-good",
|
||||
},
|
||||
},
|
||||
// Unparseable: no `token_type`.
|
||||
"https://broken.example.com": { tokens: { access_token: 42 } },
|
||||
},
|
||||
);
|
||||
serverMetadata: {
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: "https://as.example.com/authorize",
|
||||
token_endpoint: "https://as.example.com/token",
|
||||
revocation_endpoint: REVOKE_URL,
|
||||
response_types_supported: ["code"],
|
||||
},
|
||||
});
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 }),
|
||||
);
|
||||
|
|
@ -960,7 +974,7 @@ describe("revokeStoredOAuthTokens (plan + execute)", () => {
|
|||
// ...and the unreadable slot is reported rather than swallowed.
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome.status === "failed" ? outcome.detail : "").toContain(
|
||||
"corrupt slot",
|
||||
"could not read the stored grant",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1034,9 +1048,11 @@ describe("plan / clear / execute ordering", () => {
|
|||
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);
|
||||
// The caller's real sequence: take-and-clear atomically, then send.
|
||||
const plan = await clearAndPlanRevocation({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
});
|
||||
const sending = executeOAuthRevocation(plan, { fetchFn });
|
||||
|
||||
// A fresh authorization lands while the request is still out.
|
||||
|
|
@ -1061,8 +1077,11 @@ describe("plan / clear / execute ordering", () => {
|
|||
async () => new Response(null, { status: 200 }),
|
||||
);
|
||||
|
||||
const plan = await planOAuthRevocation({ serverUrl: SERVER_URL, storage });
|
||||
await storage.clear(SERVER_URL);
|
||||
const plan = await clearAndPlanRevocation({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
});
|
||||
expect(await storage.getTokens(SERVER_URL)).toBeUndefined();
|
||||
|
||||
await expect(
|
||||
executeOAuthRevocation(plan, { fetchFn }),
|
||||
|
|
@ -1071,7 +1090,7 @@ describe("plan / clear / execute ordering", () => {
|
|||
});
|
||||
|
||||
it("plans nothing when revocation is disabled", async () => {
|
||||
const plan = await planOAuthRevocation({
|
||||
const plan = await clearAndPlanRevocation({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
enabled: false,
|
||||
|
|
|
|||
|
|
@ -431,6 +431,65 @@ describe("BrowserOAuthStorage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// #2144 — the atomic take-and-clear. Split reads followed by a separate clear
|
||||
// are a check-then-act: each await yields, and an OAuth completion landing in
|
||||
// one of those gaps saves a grant the clear then destroys.
|
||||
describe("takeRevocationSnapshot", () => {
|
||||
it("returns every slot and clears the server in one step", async () => {
|
||||
await storage.saveTokens(
|
||||
testServerUrl,
|
||||
{ access_token: "a", token_type: "Bearer" },
|
||||
{ issuer: "https://as-a.example.com" },
|
||||
);
|
||||
await storage.savePreregisteredClientInformation(testServerUrl, {
|
||||
client_id: "static-cid",
|
||||
});
|
||||
await storage.saveServerMetadata(testServerUrl, {
|
||||
issuer: "https://as-a.example.com",
|
||||
authorization_endpoint: "https://as-a.example.com/authorize",
|
||||
token_endpoint: "https://as-a.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
});
|
||||
|
||||
const snapshot = await storage.takeRevocationSnapshot(testServerUrl);
|
||||
|
||||
expect(
|
||||
snapshot.byIssuer["https://as-a.example.com"]?.tokens,
|
||||
).toMatchObject({ access_token: "a" });
|
||||
expect(snapshot.preregisteredClientInformation).toMatchObject({
|
||||
client_id: "static-cid",
|
||||
});
|
||||
expect(snapshot.serverMetadata).toMatchObject({
|
||||
issuer: "https://as-a.example.com",
|
||||
});
|
||||
// Cleared in the same step.
|
||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||
expect(await storage.getServerMetadata(testServerUrl)).toBeNull();
|
||||
expect(await storage.listIssuers(testServerUrl)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the legacy unkeyed slot too", async () => {
|
||||
await storage.saveTokens(testServerUrl, {
|
||||
access_token: "legacy",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
const snapshot = await storage.takeRevocationSnapshot(testServerUrl);
|
||||
|
||||
expect(snapshot.legacyTokens).toMatchObject({ access_token: "legacy" });
|
||||
expect(snapshot.byIssuer).toEqual({});
|
||||
});
|
||||
|
||||
it("is empty for a server with no state", async () => {
|
||||
const snapshot = await storage.takeRevocationSnapshot(
|
||||
"https://unknown.example/mcp",
|
||||
);
|
||||
expect(snapshot.byIssuer).toEqual({});
|
||||
expect(snapshot.legacyTokens).toBeUndefined();
|
||||
expect(snapshot.serverMetadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearServerState", () => {
|
||||
it("should clear all state for a server", async () => {
|
||||
const clientInfo: OAuthClientInformation = {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ function createMockParams(
|
|||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
listIssuers: vi.fn().mockResolvedValue([]),
|
||||
getIssuerTokens: vi.fn().mockResolvedValue(undefined),
|
||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
saveDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
clearDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -188,45 +189,75 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
|
||||
describe("clearOAuthTokens", () => {
|
||||
it("calls storage.clear(serverUrl) when storage is configured", async () => {
|
||||
/**
|
||||
* The take-and-clear is ONE atomic storage step now, so `clear` is no
|
||||
* longer called separately — these assert on `takeRevocationSnapshot`.
|
||||
*/
|
||||
function stubSnapshot(
|
||||
storage: OAuthManagerConfig["storage"],
|
||||
revocable = true,
|
||||
): void {
|
||||
vi.mocked(storage!.takeRevocationSnapshot).mockResolvedValue({
|
||||
byIssuer: revocable
|
||||
? {
|
||||
"https://as.example.com": {
|
||||
tokens: {
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "r",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {},
|
||||
serverMetadata: {
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: "https://as.example.com/authorize",
|
||||
token_endpoint: "https://as.example.com/token",
|
||||
revocation_endpoint: "https://as.example.com/revoke",
|
||||
response_types_supported: ["code"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("takes and clears the server's state when storage is configured", async () => {
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
await manager.clearOAuthTokens();
|
||||
expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith(
|
||||
SERVER_URL,
|
||||
);
|
||||
expect(
|
||||
params.initialConfig.storage!.takeRevocationSnapshot,
|
||||
).toHaveBeenCalledWith(SERVER_URL);
|
||||
expect(manager.getOAuthFlowState()).toBeUndefined();
|
||||
expect(manager.getOAuthFlowStep()).toBeUndefined();
|
||||
});
|
||||
|
||||
// #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.
|
||||
// #2144 — the ordering is the contract. The request is built from state the
|
||||
// clear destroys, so the take must come first; but the two are ONE step, so
|
||||
// nothing can save a fresh grant between them, and the network wait happens
|
||||
// after the state is already gone.
|
||||
it("clears local state before waiting on the revocation request", async () => {
|
||||
const params = createMockParams();
|
||||
const storage = params.initialConfig.storage!;
|
||||
// Issuer-bound and matching the metadata: an unkeyed grant records no
|
||||
// authorization server and is deliberately refused.
|
||||
vi.mocked(storage.listIssuers).mockResolvedValue([
|
||||
"https://as.example.com",
|
||||
]);
|
||||
vi.mocked(storage.getIssuerTokens).mockResolvedValue({
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "r",
|
||||
});
|
||||
vi.mocked(storage.getServerMetadata).mockResolvedValue({
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: "https://as.example.com/authorize",
|
||||
token_endpoint: "https://as.example.com/token",
|
||||
revocation_endpoint: "https://as.example.com/revoke",
|
||||
response_types_supported: ["code"],
|
||||
});
|
||||
const order: string[] = [];
|
||||
vi.mocked(storage.clear).mockImplementation(async () => {
|
||||
vi.mocked(storage.takeRevocationSnapshot).mockImplementation(async () => {
|
||||
order.push("clear");
|
||||
return {
|
||||
byIssuer: {
|
||||
"https://as.example.com": {
|
||||
tokens: {
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "r",
|
||||
},
|
||||
},
|
||||
},
|
||||
serverMetadata: {
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: "https://as.example.com/authorize",
|
||||
token_endpoint: "https://as.example.com/token",
|
||||
revocation_endpoint: "https://as.example.com/revoke",
|
||||
response_types_supported: ["code"],
|
||||
},
|
||||
};
|
||||
});
|
||||
const fetchFn = vi.fn<typeof fetch>(async () => {
|
||||
order.push("revoke");
|
||||
|
|
@ -247,20 +278,7 @@ describe("OAuthManager", () => {
|
|||
it("clears local state even when the revocation request fails", async () => {
|
||||
const params = createMockParams();
|
||||
const storage = params.initialConfig.storage!;
|
||||
vi.mocked(storage.listIssuers).mockResolvedValue([
|
||||
"https://as.example.com",
|
||||
]);
|
||||
vi.mocked(storage.getIssuerTokens).mockResolvedValue({
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
vi.mocked(storage.getServerMetadata).mockResolvedValue({
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: "https://as.example.com/authorize",
|
||||
token_endpoint: "https://as.example.com/token",
|
||||
revocation_endpoint: "https://as.example.com/revoke",
|
||||
response_types_supported: ["code"],
|
||||
});
|
||||
stubSnapshot(storage);
|
||||
const manager = new OAuthManager({
|
||||
...params,
|
||||
effectiveAuthFetch: vi.fn<typeof fetch>(async () => {
|
||||
|
|
@ -271,11 +289,13 @@ describe("OAuthManager", () => {
|
|||
await expect(manager.clearOAuthTokens()).resolves.toMatchObject({
|
||||
status: "failed",
|
||||
});
|
||||
expect(storage.clear).toHaveBeenCalledWith(SERVER_URL);
|
||||
expect(storage.takeRevocationSnapshot).toHaveBeenCalledWith(SERVER_URL);
|
||||
});
|
||||
|
||||
it("skips the request when revocation is turned off", async () => {
|
||||
const params = createMockParams();
|
||||
const storage = params.initialConfig.storage!;
|
||||
stubSnapshot(storage);
|
||||
const fetchFn = vi.fn<typeof fetch>();
|
||||
const manager = new OAuthManager({
|
||||
...params,
|
||||
|
|
@ -286,9 +306,9 @@ describe("OAuthManager", () => {
|
|||
manager.clearOAuthTokens({ revoke: false }),
|
||||
).resolves.toEqual({ status: "skipped", reason: "disabled" });
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith(
|
||||
SERVER_URL,
|
||||
);
|
||||
// Still cleared — that is what the caller asked for; the request is the
|
||||
// optional part.
|
||||
expect(storage.takeRevocationSnapshot).toHaveBeenCalledWith(SERVER_URL);
|
||||
});
|
||||
|
||||
it("no-ops when storage is not configured", async () => {
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ import {
|
|||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
|
||||
import {
|
||||
clearAndPlanRevocation,
|
||||
executeOAuthRevocation,
|
||||
planOAuthRevocation,
|
||||
} from "@inspector/core/auth/revocation.js";
|
||||
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
|
||||
import type { OAuthMetadata } from "@modelcontextprotocol/client";
|
||||
|
|
@ -203,14 +203,13 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
|||
});
|
||||
|
||||
/**
|
||||
* The caller's real sequence — snapshot, clear, send — so this exercises the
|
||||
* ordering the product uses rather than a convenience wrapper.
|
||||
* The caller's real sequence — take-and-clear atomically, then 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);
|
||||
const plan = await clearAndPlanRevocation({ serverUrl, storage });
|
||||
return executeOAuthRevocation(plan, { fetchFn: fetch });
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue