fix: use Node storage in the revocation e2e, and drop the now-dead storage API (#2144)
The CI coverage job failed with `ReferenceError: sessionStorage is not defined`. `revocation-e2e.test.ts` used `BrowserOAuthStorage`, but the `integration` vitest project runs in the node environment, not happy-dom. Node 22 gates Web Storage behind a flag while newer Node exposes it by default, so it passed locally and failed in CI. It uses `NodeOAuthStorage` with a temp dir now, matching the other OAuth integration tests, with a comment naming the trap. Also from Copilot review round 21: `listIssuers` and `getIssuerTokens` became dead production API when `takeRevocationSnapshot` took over — a grep finds them only in their own implementations and tests. Keeping them would widen the exported `OAuthStorage` contract and force every implementation and mock to carry methods nothing calls. Removed, along with their tests and the mock entries in four suites. 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
1f2e3e1744
commit
eede9c17ba
8 changed files with 21 additions and 130 deletions
|
|
@ -57,8 +57,6 @@ function createStorage(
|
||||||
clearServerMetadata: vi.fn(),
|
clearServerMetadata: vi.fn(),
|
||||||
clearIdpSession: vi.fn(),
|
clearIdpSession: vi.fn(),
|
||||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||||
listIssuers: vi.fn().mockResolvedValue([]),
|
|
||||||
getIssuerTokens: vi.fn().mockResolvedValue(undefined),
|
|
||||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||||
getCodeVerifier: vi.fn(),
|
getCodeVerifier: vi.fn(),
|
||||||
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,6 @@ function createMemoryStorage(
|
||||||
saveScope: vi.fn(),
|
saveScope: vi.fn(),
|
||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||||
listIssuers: vi.fn().mockResolvedValue([]),
|
|
||||||
getIssuerTokens: vi.fn().mockResolvedValue(undefined),
|
|
||||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||||
} as unknown as OAuthStorage;
|
} as unknown as OAuthStorage;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,6 @@ describe("idpSession", () => {
|
||||||
clearIdpSession: vi.fn(),
|
clearIdpSession: vi.fn(),
|
||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||||
listIssuers: vi.fn().mockResolvedValue([]),
|
|
||||||
getIssuerTokens: vi.fn().mockResolvedValue(undefined),
|
|
||||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||||
} as unknown as OAuthStorage;
|
} as unknown as OAuthStorage;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -358,79 +358,6 @@ 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(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The no-fallback read #2144 needs: `getTokens(url, issuer)` deliberately
|
|
||||||
// falls back to the legacy unkeyed slot, which is right for a connect and
|
|
||||||
// wrong for anything enumerating issuers.
|
|
||||||
describe("getIssuerTokens", () => {
|
|
||||||
it("returns only the tokens bound to that issuer", async () => {
|
|
||||||
await storage.saveTokens(
|
|
||||||
testServerUrl,
|
|
||||||
{ access_token: "a", token_type: "Bearer" },
|
|
||||||
{ issuer: "https://as-a.example.com" },
|
|
||||||
);
|
|
||||||
const tokens = await storage.getIssuerTokens(
|
|
||||||
testServerUrl,
|
|
||||||
"https://as-a.example.com",
|
|
||||||
);
|
|
||||||
expect(tokens?.access_token).toBe("a");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not fall back to the legacy unkeyed token", async () => {
|
|
||||||
await storage.saveTokens(testServerUrl, {
|
|
||||||
access_token: "legacy",
|
|
||||||
token_type: "Bearer",
|
|
||||||
});
|
|
||||||
// `getTokens` DOES fall back — that contrast is the point of the method.
|
|
||||||
expect((await storage.getTokens(testServerUrl))?.access_token).toBe(
|
|
||||||
"legacy",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
await storage.getIssuerTokens(
|
|
||||||
testServerUrl,
|
|
||||||
"https://as-a.example.com",
|
|
||||||
),
|
|
||||||
).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// #2144 — the atomic take-and-clear. Split reads followed by a separate clear
|
// #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
|
// 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.
|
// one of those gaps saves a grant the clear then destroys.
|
||||||
|
|
@ -465,7 +392,9 @@ describe("BrowserOAuthStorage", () => {
|
||||||
// Cleared in the same step.
|
// Cleared in the same step.
|
||||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||||
expect(await storage.getServerMetadata(testServerUrl)).toBeNull();
|
expect(await storage.getServerMetadata(testServerUrl)).toBeNull();
|
||||||
expect(await storage.listIssuers(testServerUrl)).toEqual([]);
|
expect(
|
||||||
|
(await storage.takeRevocationSnapshot(testServerUrl)).byIssuer,
|
||||||
|
).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the legacy unkeyed slot too", async () => {
|
it("returns the legacy unkeyed slot too", async () => {
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,6 @@ function createMockParams(
|
||||||
saveIdpSession: vi.fn().mockResolvedValue(undefined),
|
saveIdpSession: vi.fn().mockResolvedValue(undefined),
|
||||||
clearIdpSession: vi.fn(),
|
clearIdpSession: vi.fn(),
|
||||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||||
listIssuers: vi.fn().mockResolvedValue([]),
|
|
||||||
getIssuerTokens: vi.fn().mockResolvedValue(undefined),
|
|
||||||
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
takeRevocationSnapshot: vi.fn().mockResolvedValue({ byIssuer: {} }),
|
||||||
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||||
saveDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
saveDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,21 @@
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
import { createHash, randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import {
|
import {
|
||||||
TestServerHttp,
|
TestServerHttp,
|
||||||
createOAuthTestServerConfig,
|
createOAuthTestServerConfig,
|
||||||
getDefaultServerConfig,
|
getDefaultServerConfig,
|
||||||
waitForOAuthWellKnown,
|
waitForOAuthWellKnown,
|
||||||
} from "@modelcontextprotocol/inspector-test-server";
|
} from "@modelcontextprotocol/inspector-test-server";
|
||||||
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
|
// NOT `BrowserOAuthStorage`: the `integration` project runs in the node
|
||||||
|
// environment, where `sessionStorage` does not exist. Node 22 gates Web Storage
|
||||||
|
// behind a flag and newer Node exposes it by default, so a browser-backed store
|
||||||
|
// passes on a developer's machine and fails in CI — which is exactly what
|
||||||
|
// happened here.
|
||||||
|
import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js";
|
||||||
import {
|
import {
|
||||||
clearAndPlanRevocation,
|
clearAndPlanRevocation,
|
||||||
executeOAuthRevocation,
|
executeOAuthRevocation,
|
||||||
|
|
@ -43,6 +51,7 @@ function base64Url(buffer: Buffer): string {
|
||||||
describe("OAuth token revocation (RFC 7009)", () => {
|
describe("OAuth token revocation (RFC 7009)", () => {
|
||||||
let mcpServer: TestServerHttp | null = null;
|
let mcpServer: TestServerHttp | null = null;
|
||||||
let serverUrl = "";
|
let serverUrl = "";
|
||||||
|
let storageDir = "";
|
||||||
let metadata: OAuthMetadata;
|
let metadata: OAuthMetadata;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
|
@ -67,6 +76,7 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
||||||
});
|
});
|
||||||
const port = await mcpServer.start();
|
const port = await mcpServer.start();
|
||||||
serverUrl = `http://localhost:${port}`;
|
serverUrl = `http://localhost:${port}`;
|
||||||
|
storageDir = mkdtempSync(join(tmpdir(), "mcp-inspector-2144-"));
|
||||||
await waitForOAuthWellKnown(serverUrl);
|
await waitForOAuthWellKnown(serverUrl);
|
||||||
metadata = (await (
|
metadata = (await (
|
||||||
await fetch(`${serverUrl}/.well-known/oauth-authorization-server`)
|
await fetch(`${serverUrl}/.well-known/oauth-authorization-server`)
|
||||||
|
|
@ -76,6 +86,10 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await mcpServer?.stop();
|
await mcpServer?.stop();
|
||||||
mcpServer = null;
|
mcpServer = null;
|
||||||
|
if (storageDir) {
|
||||||
|
rmSync(storageDir, { recursive: true, force: true });
|
||||||
|
storageDir = "";
|
||||||
|
}
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
/** Run a real authorization-code exchange and return the issued tokens. */
|
/** Run a real authorization-code exchange and return the issued tokens. */
|
||||||
|
|
@ -135,8 +149,8 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
||||||
async function seededStorage(tokens: {
|
async function seededStorage(tokens: {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
}): Promise<BrowserOAuthStorage> {
|
}): Promise<NodeOAuthStorage> {
|
||||||
const storage = new BrowserOAuthStorage();
|
const storage = new NodeOAuthStorage(join(storageDir, "oauth.json"));
|
||||||
await storage.clear(serverUrl);
|
await storage.clear(serverUrl);
|
||||||
// Issuer-bound and matching the discovered metadata: an unkeyed grant
|
// Issuer-bound and matching the discovered metadata: an unkeyed grant
|
||||||
// records no authorization server and is deliberately refused.
|
// records no authorization server and is deliberately refused.
|
||||||
|
|
@ -207,7 +221,7 @@ describe("OAuth token revocation (RFC 7009)", () => {
|
||||||
* exercises the ordering the product uses rather than a convenience wrapper.
|
* exercises the ordering the product uses rather than a convenience wrapper.
|
||||||
*/
|
*/
|
||||||
async function clearAndRevoke(
|
async function clearAndRevoke(
|
||||||
storage: BrowserOAuthStorage,
|
storage: NodeOAuthStorage,
|
||||||
): Promise<TokenRevocationOutcome> {
|
): Promise<TokenRevocationOutcome> {
|
||||||
const plan = await clearAndPlanRevocation({ serverUrl, storage });
|
const plan = await clearAndPlanRevocation({ serverUrl, storage });
|
||||||
return executeOAuthRevocation(plan, { fetchFn: fetch });
|
return executeOAuthRevocation(plan, { fetchFn: fetch });
|
||||||
|
|
|
||||||
|
|
@ -447,23 +447,6 @@ export class OAuthStorageBase implements OAuthStorage {
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getIssuerTokens(
|
|
||||||
serverUrl: string,
|
|
||||||
issuer: string,
|
|
||||||
): Promise<OAuthTokens | undefined> {
|
|
||||||
await this.ensureLoaded();
|
|
||||||
const state = this.memory.getState().getServerState(serverUrl);
|
|
||||||
const tokens = state.byIssuer?.[issuer]?.tokens;
|
|
||||||
if (!tokens) return undefined;
|
|
||||||
return withIssuer(await OAuthTokensSchema.parseAsync(tokens), issuer);
|
|
||||||
}
|
|
||||||
|
|
||||||
async listIssuers(serverUrl: string): Promise<string[]> {
|
|
||||||
await this.ensureLoaded();
|
|
||||||
const state = this.memory.getState().getServerState(serverUrl);
|
|
||||||
return Object.keys(state.byIssuer ?? {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async clear(serverUrl: string): Promise<void> {
|
async clear(serverUrl: string): Promise<void> {
|
||||||
await this.ensureLoaded();
|
await this.ensureLoaded();
|
||||||
this.memory.getState().clearServerState(serverUrl);
|
this.memory.getState().clearServerState(serverUrl);
|
||||||
|
|
|
||||||
|
|
@ -223,33 +223,6 @@ export interface OAuthStorage {
|
||||||
*/
|
*/
|
||||||
takeRevocationSnapshot(serverUrl: string): Promise<RevocationSnapshot>;
|
takeRevocationSnapshot(serverUrl: string): Promise<RevocationSnapshot>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Tokens bound to **exactly** `issuer`, with no legacy-unkeyed fallback.
|
|
||||||
*
|
|
||||||
* {@link getTokens} deliberately falls back to the legacy unkeyed slot when
|
|
||||||
* the issuer slot holds none — that is what keeps a pre-SEP-2352 entry
|
|
||||||
* working. But a caller enumerating {@link listIssuers} must not treat that
|
|
||||||
* fallback as belonging to the issuer it happened to ask for: during a
|
|
||||||
* partially migrated flow it would label an old, unbound token with a newly
|
|
||||||
* discovered authorization server and send it there (#2144).
|
|
||||||
*/
|
|
||||||
getIssuerTokens(
|
|
||||||
serverUrl: string,
|
|
||||||
issuer: string,
|
|
||||||
): Promise<OAuthTokens | undefined>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The authorization-server `issuer` keys holding credentials for this server
|
|
||||||
* (SEP-2352). Empty when the entry predates issuer binding — its credentials
|
|
||||||
* live in the legacy unkeyed slot, which the ctx-less reads above answer.
|
|
||||||
*
|
|
||||||
* Exists because {@link clear} deletes **every** issuer slot: anything that
|
|
||||||
* must act on the credentials before they are dropped (RFC 7009 revocation,
|
|
||||||
* #2144) would otherwise see only the active issuer's and silently discard
|
|
||||||
* the rest.
|
|
||||||
*/
|
|
||||||
listIssuers(serverUrl: string): Promise<string[]>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all OAuth data for a server
|
* Clear all OAuth data for a server
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue