- `revokeStoredKeys`' pre-read of `getTokens` sat outside the best-effort catch. `getTokens` parses through `OAuthTokensSchema`, so a persisted token that no longer validates rejected and abandoned the local delete `--relogin` promises — over a grant that could not have been revoked anyway. Caught and retained as a failed outcome; the clear proceeds. - The multi-key bookkeeping was wrong twice. A token is now marked spent only on a `revoked` outcome, so a failed or unsupported attempt no longer skips a duplicate entry under the other key that may hold the credentials or metadata that would have worked. And a failure now outranks an earlier success for reporting, so a stale grant left live cannot be hidden behind the first key's 200. - `--no-revoke` is now rejected without `--relogin`, ahead of the short-circuit returns, matching the parser's existing stance on accepted-but-inert flags (`--strict`). On its own it reads as "this run will not revoke anything", true only because nothing was being cleared. - Extracted the App draft merge as `utils/serverWithDraftSettings`, so the rule has a test. The hook tests receive whatever they are handed and cannot tell a draft from a persisted entry, and `App.test.tsx`'s settings harness connects a stdio server where the OAuth section never renders. 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>
219 lines
6.6 KiB
TypeScript
219 lines
6.6 KiB
TypeScript
/**
|
|
* `--relogin` token revocation, driven through `runCli` (#2144).
|
|
*
|
|
* The helper's own tests cover `clearStoredAuthForRelogin({ revoke })`. What
|
|
* they cannot cover is the wiring above it: that Commander maps `--no-revoke`,
|
|
* that the per-server `oauth.revokeOnClear` reaches the call, and that a failed
|
|
* revocation is reported without changing what `--relogin` does. A regression
|
|
* in any of those turns revocation silently back on or off, which is precisely
|
|
* the class of bug the opt-out exists to make controllable.
|
|
*
|
|
* Every case here fails to connect on purpose — the fetch double refuses
|
|
* everything but the revocation endpoint. That is fine: revocation runs before
|
|
* the connect, so the assertions are about the requests made and the warnings
|
|
* printed, not about the exit code.
|
|
*/
|
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { resetNodeOAuthStorageCache } from "@inspector/core/auth/node/storage-node.js";
|
|
import { runCli } from "./helpers/cli-runner.js";
|
|
|
|
const SERVER_URL = "https://example.com/mcp";
|
|
const REVOKE_URL = "https://as.example.com/revoke";
|
|
|
|
let dir: string | undefined;
|
|
let prevPath: string | undefined;
|
|
|
|
afterEach(() => {
|
|
if (prevPath === undefined) delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
|
else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = prevPath;
|
|
prevPath = undefined;
|
|
resetNodeOAuthStorageCache();
|
|
vi.restoreAllMocks();
|
|
if (dir) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
dir = undefined;
|
|
}
|
|
});
|
|
|
|
/** Seed an OAuth store holding a revocable grant for {@link SERVER_URL}. */
|
|
function seedStore(): void {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-revoke-e2e-"));
|
|
const file = path.join(dir, "oauth.json");
|
|
fs.writeFileSync(
|
|
file,
|
|
JSON.stringify({
|
|
servers: {
|
|
[SERVER_URL]: {
|
|
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: REVOKE_URL,
|
|
response_types_supported: ["code"],
|
|
},
|
|
},
|
|
},
|
|
idpSessions: {},
|
|
}),
|
|
"utf8",
|
|
);
|
|
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
|
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
|
resetNodeOAuthStorageCache();
|
|
}
|
|
|
|
/**
|
|
* Answer the revocation endpoint with `status` and refuse everything else, so
|
|
* the run stops at the connect instead of reaching the network.
|
|
*/
|
|
function stubFetch(status = 200): ReturnType<typeof vi.spyOn> {
|
|
return vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
|
|
if (String(input instanceof Request ? input.url : input) === REVOKE_URL) {
|
|
return Promise.resolve(new Response(null, { status }));
|
|
}
|
|
return Promise.reject(new Error("network blocked in test"));
|
|
}) as ReturnType<typeof vi.spyOn>;
|
|
}
|
|
|
|
function revocationCalls(spy: { mock: { calls: unknown[][] } }): unknown[][] {
|
|
return spy.mock.calls.filter(
|
|
(call) => String(call[0] as string) === REVOKE_URL,
|
|
);
|
|
}
|
|
|
|
/** A catalog holding one HTTP server, optionally opting out of revocation. */
|
|
function writeCatalog(revokeOnClear?: boolean): string {
|
|
const catalogDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "cli-relogin-catalog-"),
|
|
);
|
|
const catalogPath = path.join(catalogDir, "mcp.json");
|
|
fs.writeFileSync(
|
|
catalogPath,
|
|
JSON.stringify({
|
|
mcpServers: {
|
|
remote: {
|
|
type: "streamable-http",
|
|
url: SERVER_URL,
|
|
...(revokeOnClear !== undefined && { oauth: { revokeOnClear } }),
|
|
},
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
return catalogPath;
|
|
}
|
|
|
|
describe("--relogin token revocation", () => {
|
|
it("revokes the stored grant by default", async () => {
|
|
seedStore();
|
|
const fetchSpy = stubFetch();
|
|
|
|
await runCli([
|
|
"--relogin",
|
|
"--server-url",
|
|
SERVER_URL,
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expect(revocationCalls(fetchSpy)).toHaveLength(1);
|
|
});
|
|
|
|
// Commander turns `--no-revoke` into `options.revoke === false`; nothing else
|
|
// proves that mapping survives a refactor of the flag.
|
|
it("--no-revoke skips the request", async () => {
|
|
seedStore();
|
|
const fetchSpy = stubFetch();
|
|
|
|
await runCli([
|
|
"--relogin",
|
|
"--no-revoke",
|
|
"--server-url",
|
|
SERVER_URL,
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expect(revocationCalls(fetchSpy)).toHaveLength(0);
|
|
});
|
|
|
|
it("honors the per-server oauth.revokeOnClear opt-out", async () => {
|
|
seedStore();
|
|
const catalogPath = writeCatalog(false);
|
|
const fetchSpy = stubFetch();
|
|
try {
|
|
await runCli([
|
|
"--catalog",
|
|
catalogPath,
|
|
"--server",
|
|
"remote",
|
|
"--relogin",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
expect(revocationCalls(fetchSpy)).toHaveLength(0);
|
|
} finally {
|
|
fs.rmSync(path.dirname(catalogPath), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("revokes for a catalog server that did not opt out", async () => {
|
|
seedStore();
|
|
const catalogPath = writeCatalog();
|
|
const fetchSpy = stubFetch();
|
|
try {
|
|
await runCli([
|
|
"--catalog",
|
|
catalogPath,
|
|
"--server",
|
|
"remote",
|
|
"--relogin",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
expect(revocationCalls(fetchSpy)).toHaveLength(1);
|
|
} finally {
|
|
fs.rmSync(path.dirname(catalogPath), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Inert-but-accepted flags are rejected in this parser (see the `--strict`
|
|
// rationale in `cli.ts`). On its own `--no-revoke` reads as "this run will
|
|
// not revoke anything", which is true only because nothing was being cleared.
|
|
it("rejects --no-revoke without --relogin", async () => {
|
|
const result = await runCli([
|
|
"--no-revoke",
|
|
"--server-url",
|
|
SERVER_URL,
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
expect(result.stderr).toMatch(/--no-revoke requires --relogin/);
|
|
});
|
|
|
|
// A failed revocation must be visible — the grant is still live at the
|
|
// authorization server — without turning `--relogin` into a failure, which
|
|
// is a local delete the user still gets.
|
|
it("warns on stderr when the authorization server refuses", async () => {
|
|
seedStore();
|
|
stubFetch(500);
|
|
|
|
const result = await runCli([
|
|
"--relogin",
|
|
"--server-url",
|
|
SERVER_URL,
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expect(result.stderr).toMatch(/could not revoke the OAuth grant/i);
|
|
});
|
|
});
|