Clearing the Inspector's OAuth state deleted the local copy of the tokens and stopped there. The access token — and the refresh token, which is long-lived by design — stayed valid at the authorization server until they expired on their own, so a day of connect/disconnect iteration left it holding grants for sessions that ended hours ago. RFC 7009 §1 describes exactly that case, and nothing in the repo constructed a revocation request, so a server author implementing `revocation_endpoint` had no way to exercise it with the tool that exists to exercise this. core/auth/revocation.ts builds and sends the request; the three clear paths call it, so the behavior is written once — web's "Clear OAuth state and disconnect" (both the active and stored-only paths), the TUI's Auth tab, and the CLI's --relogin. It runs BEFORE the local clear, because the token, the client credentials and the discovered `revocation_endpoint` all live in the store the clear is about to empty. The request names the refresh token when there is one: §2.1 asks the AS to also invalidate the access tokens issued under the same grant, so one request covers both halves. Best-effort by construction. Every path returns a TokenRevocationOutcome rather than throwing: no advertised endpoint means nothing is sent and that authorization server behaves exactly as before, and a network error, a non-2xx, a 5s timeout or an unreadable store is reported and nothing more. Forgetting the tokens is what the user asked for. `lost_authorization_state` recovery deliberately skips it — it clears a half-finished flow to retry it, and an authorization that never completed has no grant to revoke. Client authentication resolves the preregistered slot first, mirroring BaseOAuthClientProvider.clientInformation; reading only the dynamic slot would send no authentication for a server configured with `oauth.clientId`. The Basic credential is byte-identical to the SDK's applyBasicAuth so this request and the token request cannot present the same secret differently. Opt out per server with `oauth.revokeOnClear` (default on, only `false` written to disk) or per CLI run with --no-revoke. Read at clear time rather than connect time, so toggling needs no reconnect. Turning it off is a testing affordance: a client that walks away still holding live tokens is a case a server author may want to reproduce. test-servers now advertises `revocation_endpoint` and serves POST /oauth/revoke, faithful on §2.2 (an unknown token is a success) and §2.1 (revoking a refresh token invalidates the access tokens under the same grant), with oauth-revocation-http.json / oauth-no-revocation-http.json as showcase configs. 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>
245 lines
8.7 KiB
TypeScript
245 lines
8.7 KiB
TypeScript
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 {
|
|
getStateFilePath,
|
|
resetNodeOAuthStorageCache,
|
|
} from "@inspector/core/auth/node/storage-node.js";
|
|
import { clearStoredAuthForRelogin } from "../src/clear-stored-auth-for-relogin.js";
|
|
|
|
describe("clearStoredAuthForRelogin", () => {
|
|
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;
|
|
resetNodeOAuthStorageCache();
|
|
if (dir) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
dir = undefined;
|
|
}
|
|
});
|
|
|
|
it("no-ops when serverUrl is missing or blank", async () => {
|
|
await clearStoredAuthForRelogin(undefined);
|
|
await clearStoredAuthForRelogin(" ");
|
|
});
|
|
|
|
it("clears a URL-keyed entry and tolerates non-URL keys", async () => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-"));
|
|
const file = path.join(dir, "oauth.json");
|
|
fs.writeFileSync(
|
|
file,
|
|
JSON.stringify({
|
|
servers: {
|
|
"https://example.com/mcp": {
|
|
tokens: { access_token: "a", token_type: "Bearer" },
|
|
},
|
|
"not a url": {
|
|
tokens: { access_token: "b", token_type: "Bearer" },
|
|
},
|
|
},
|
|
idpSessions: {},
|
|
}),
|
|
"utf8",
|
|
);
|
|
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
|
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
|
resetNodeOAuthStorageCache();
|
|
expect(getStateFilePath()).toBe(file);
|
|
|
|
await clearStoredAuthForRelogin("https://example.com/mcp");
|
|
let blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
|
servers: Record<string, unknown>;
|
|
};
|
|
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
|
|
expect(blob.servers["not a url"]).toBeDefined();
|
|
|
|
// normalizeServerUrl catch path — clears under the raw key
|
|
await clearStoredAuthForRelogin("not a url");
|
|
blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
|
servers: Record<string, unknown>;
|
|
};
|
|
expect(blob.servers["not a url"]).toBeUndefined();
|
|
});
|
|
|
|
// #2144 — RFC 7009. The request has to go out *before* the delete, since it
|
|
// is built from the token, the client id and the cached metadata the delete
|
|
// removes.
|
|
describe("token revocation", () => {
|
|
function seed(over: Record<string, unknown> = {}): string {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-revoke-"));
|
|
const file = path.join(dir, "oauth.json");
|
|
fs.writeFileSync(
|
|
file,
|
|
JSON.stringify({
|
|
servers: {
|
|
"https://example.com/mcp": {
|
|
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"],
|
|
},
|
|
...over,
|
|
},
|
|
},
|
|
idpSessions: {},
|
|
}),
|
|
"utf8",
|
|
);
|
|
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
|
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
|
resetNodeOAuthStorageCache();
|
|
return file;
|
|
}
|
|
|
|
it("revokes the stored grant before deleting it", async () => {
|
|
const file = seed();
|
|
const fetchSpy = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockResolvedValue(new Response(null, { status: 200 }));
|
|
try {
|
|
await expect(
|
|
clearStoredAuthForRelogin("https://example.com/mcp"),
|
|
).resolves.toMatchObject({
|
|
status: "revoked",
|
|
tokenTypeHint: "refresh_token",
|
|
});
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchSpy.mock.calls[0]!;
|
|
expect(String(url)).toBe("https://as.example.com/revoke");
|
|
expect(new URLSearchParams(String(init?.body)).get("token")).toBe("r");
|
|
} finally {
|
|
fetchSpy.mockRestore();
|
|
}
|
|
const blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
|
servers: Record<string, unknown>;
|
|
};
|
|
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
|
|
});
|
|
|
|
// Both key spellings are cleared, but they are two spellings of one server:
|
|
// a second request would name a grant the first one already ended. The raw
|
|
// key here holds nothing, so this also proves the walk does not stop at the
|
|
// first empty one.
|
|
it("sends one request even though both key spellings are cleared", async () => {
|
|
seed();
|
|
const fetchSpy = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockResolvedValue(new Response(null, { status: 200 }));
|
|
try {
|
|
// Normalises to the stored `https://example.com/mcp`.
|
|
await expect(
|
|
clearStoredAuthForRelogin("https://Example.com/mcp"),
|
|
).resolves.toMatchObject({ status: "revoked" });
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
fetchSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("deletes the local entry even when the request fails", async () => {
|
|
const file = seed();
|
|
const fetchSpy = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockRejectedValue(new Error("unreachable"));
|
|
try {
|
|
await expect(
|
|
clearStoredAuthForRelogin("https://example.com/mcp"),
|
|
).resolves.toMatchObject({ status: "failed" });
|
|
} finally {
|
|
fetchSpy.mockRestore();
|
|
}
|
|
const blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
|
servers: Record<string, unknown>;
|
|
};
|
|
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
|
|
});
|
|
|
|
it("skips the request when revocation is turned off", async () => {
|
|
seed();
|
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
try {
|
|
await expect(
|
|
clearStoredAuthForRelogin("https://example.com/mcp", {
|
|
revoke: false,
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
fetchSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("reports no_tokens when the store holds nothing for the server", async () => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-empty-"));
|
|
const file = path.join(dir, "oauth.json");
|
|
fs.writeFileSync(
|
|
file,
|
|
JSON.stringify({ servers: {}, idpSessions: {} }),
|
|
"utf8",
|
|
);
|
|
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
|
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
|
resetNodeOAuthStorageCache();
|
|
|
|
await expect(
|
|
clearStoredAuthForRelogin("https://example.com/mcp"),
|
|
).resolves.toEqual({ status: "skipped", reason: "no_tokens" });
|
|
});
|
|
});
|
|
|
|
it("clears both raw and URL-normalised keys (bare origin / mixed-case host)", async () => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-norm-"));
|
|
const file = path.join(dir, "oauth.json");
|
|
// Runtime storage keys by the transport's raw url string — often without a
|
|
// trailing slash / with mixed-case host — while new URL().href normalises.
|
|
fs.writeFileSync(
|
|
file,
|
|
JSON.stringify({
|
|
servers: {
|
|
"https://example.com": {
|
|
tokens: { access_token: "bare", token_type: "Bearer" },
|
|
},
|
|
"https://example.com/": {
|
|
tokens: { access_token: "slash", token_type: "Bearer" },
|
|
},
|
|
"https://Example.com/mcp": {
|
|
tokens: { access_token: "mixed", token_type: "Bearer" },
|
|
},
|
|
},
|
|
idpSessions: {},
|
|
}),
|
|
"utf8",
|
|
);
|
|
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
|
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
|
resetNodeOAuthStorageCache();
|
|
|
|
await clearStoredAuthForRelogin("https://example.com");
|
|
let blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
|
servers: Record<string, unknown>;
|
|
};
|
|
expect(blob.servers["https://example.com"]).toBeUndefined();
|
|
expect(blob.servers["https://example.com/"]).toBeUndefined();
|
|
expect(blob.servers["https://Example.com/mcp"]).toBeDefined();
|
|
|
|
await clearStoredAuthForRelogin("https://Example.com/mcp");
|
|
blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
|
servers: Record<string, unknown>;
|
|
};
|
|
expect(blob.servers["https://Example.com/mcp"]).toBeUndefined();
|
|
// Normalised key (lowercased host) is cleared too when distinct.
|
|
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
|
|
});
|
|
});
|