Merge remote-tracking branch 'origin/v2/main' into v2/fix/2172-oidc-discovery-compat
Signed-off-by: cliffhall <cliff@futurescale.com> # Conflicts: # README.md # core/mcp/inspectorClient.ts # test-servers/src/test-server-oauth.ts
This commit is contained in:
commit
2eeab05d00
5 changed files with 8524 additions and 1598 deletions
|
|
@ -123,8 +123,26 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
|
|||
| `--strict` | With `--method tools/list`: report tool-schema portability problems in full (path, issue, suggested fix) on stderr, and exit `6` if any is error-severity. Without it, a one-line count is printed instead. See [Schema portability](#schema-portability---strict). |
|
||||
| `--format <text\|json>` | Output format. `text` (default) pretty-prints the result. `json` emits a single JSON object on stdout (`{ "result": … }`, plus `{ "appInfo": … }` as a sibling key for App tools) with no banners, so the whole output pipes cleanly into `jq`. |
|
||||
| `--relogin` | Delete stored OAuth for this server URL from the shared store before connect; interactive login still only runs if the server requires auth. Requires an HTTP/SSE URL (rejected for stdio). Conflicts with `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth` / catalog short-circuits. |
|
||||
| `--no-revoke` | With `--relogin`, skip the [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009) revocation request that would otherwise end the grant at the authorization server when the local state is deleted. The per-server `oauth.revokeOnClear` setting is the persistent form of the same opt-out; either one is enough to skip it. See [Revoking on `--relogin`](#revoking-on---relogin). |
|
||||
| `--stored-auth-only` | **CI / non-interactive safe:** never start interactive OAuth / step-up (and never auto-open a browser); use the shared store if present, otherwise fail immediately with `auth_required`. Prefer this over a bare pipe/CI run that would otherwise attempt interactive login. |
|
||||
|
||||
#### Revoking on `--relogin`
|
||||
|
||||
`--relogin` deletes this server's stored OAuth state so the next connect cannot silently reuse it. By default it now also **revokes the grant at the authorization server**, per [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009) ([#2144](https://github.com/modelcontextprotocol/inspector/issues/2144)) — otherwise the delete is invisible to the AS, and the access token, plus the refresh token when one was issued, stay valid there until they expire on their own.
|
||||
|
||||
The request is built from the stored state before the delete and sent after it, so the local delete never waits on the network. That matters because the OAuth store is shared: holding this process's view of it across a five-second request would let another CLI or TUI write a fresh grant that this one then erased.
|
||||
|
||||
The request names the refresh token when there is one: RFC 7009 §2.1 asks the authorization server to invalidate the access tokens issued under the same grant, so a single request covers both.
|
||||
|
||||
It is best-effort and never changes the exit code. An authorization server that advertises no `revocation_endpoint` gets no request at all; a network error, a non-2xx, or the short timeout prints a one-line warning on stderr and the local delete proceeds either way.
|
||||
|
||||
Turn it off per run with `--no-revoke`, or per server with `oauth.revokeOnClear: false` in the catalog — either is enough, and neither can turn it on for the other. Disconnecting while still holding live tokens is a case worth reproducing when the server under test is the thing being debugged.
|
||||
|
||||
```bash
|
||||
mcp-inspector --cli --server-url https://example.com/mcp --relogin --method tools/list
|
||||
mcp-inspector --cli --server-url https://example.com/mcp --relogin --no-revoke --method tools/list
|
||||
```
|
||||
|
||||
`servers/show` redacts secret-bearing fields (`env` values, sensitive headers, sensitive `settings.metadata` keys whose whole value is replaced whether or not it is structured, `requestInit` / `eventSourceInit` headers, `oauthClientSecret`). It does **not** scrub credentials embedded in a server `url` (userinfo or query tokens) or in stdio `args` — treat `detail` / raw URL fields as potentially sensitive before pasting into issues.
|
||||
|
||||
#### App probing (`--app-info`) and machine-readable output (`--format json`)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
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";
|
||||
|
|
@ -8,6 +8,8 @@ import {
|
|||
} from "@inspector/core/auth/node/storage-node.js";
|
||||
import { clearStoredAuthForRelogin } from "../src/clear-stored-auth-for-relogin.js";
|
||||
|
||||
const AS_ISSUER = "https://as.example.com";
|
||||
|
||||
describe("clearStoredAuthForRelogin", () => {
|
||||
let dir: string | undefined;
|
||||
let prevPath: string | undefined;
|
||||
|
|
@ -66,6 +68,386 @@ describe("clearStoredAuthForRelogin", () => {
|
|||
expect(blob.servers["not a url"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// #2144 — RFC 7009. The request is *built* before the delete (from the token,
|
||||
// the client id and the cached metadata it removes) and *sent* after it, so
|
||||
// the local delete never waits on the network.
|
||||
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: {
|
||||
// Issuer-bound (SEP-2352), matching the cached metadata below: an
|
||||
// unkeyed grant records no authorization server and is refused, so
|
||||
// seeding one would make these cases assert that instead.
|
||||
"https://example.com/mcp": {
|
||||
activeIssuer: AS_ISSUER,
|
||||
byIssuer: {
|
||||
[AS_ISSUER]: {
|
||||
tokens: {
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "r",
|
||||
},
|
||||
},
|
||||
},
|
||||
serverMetadata: {
|
||||
issuer: AS_ISSUER,
|
||||
authorization_endpoint: `${AS_ISSUER}/authorize`,
|
||||
token_endpoint: `${AS_ISSUER}/token`,
|
||||
revocation_endpoint: `${AS_ISSUER}/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;
|
||||
}
|
||||
|
||||
/** Seed both key spellings of one server, each with its own refresh token. */
|
||||
function seedBothSpellings(
|
||||
normalizedToken: string,
|
||||
rawToken: string,
|
||||
): void {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-two-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
const metadata = {
|
||||
issuer: AS_ISSUER,
|
||||
authorization_endpoint: `${AS_ISSUER}/authorize`,
|
||||
token_endpoint: `${AS_ISSUER}/token`,
|
||||
revocation_endpoint: `${AS_ISSUER}/revoke`,
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
const entry = (refresh: string) => ({
|
||||
activeIssuer: AS_ISSUER,
|
||||
byIssuer: {
|
||||
[AS_ISSUER]: {
|
||||
tokens: {
|
||||
access_token: `a-${refresh}`,
|
||||
token_type: "Bearer",
|
||||
refresh_token: refresh,
|
||||
},
|
||||
},
|
||||
},
|
||||
serverMetadata: metadata,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
servers: {
|
||||
"https://example.com": entry(rawToken),
|
||||
"https://example.com/": entry(normalizedToken),
|
||||
},
|
||||
idpSessions: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
||||
resetNodeOAuthStorageCache();
|
||||
}
|
||||
|
||||
it("revokes the stored grant, having snapshotted it before the delete", 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 spellings resolve to the same stored entry, so this is one grant and
|
||||
// must produce one request. It also proves the walk does not stop at the
|
||||
// first empty key: the raw spelling holds nothing.
|
||||
it("sends one request when both key spellings name the same grant", 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();
|
||||
}
|
||||
});
|
||||
|
||||
// Both keys are deleted, so both must be revoked: a stale entry under the
|
||||
// other spelling is still a live grant at the authorization server, and
|
||||
// dropping it locally without revoking is the exact leak this closes.
|
||||
// The *reported* outcome is the normalised key's, matching the precedence
|
||||
// `findStoredServerState` reads with — that is the grant a connect uses.
|
||||
it("revokes a stale entry under the other spelling too", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-both-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
const metadata = {
|
||||
issuer: AS_ISSUER,
|
||||
authorization_endpoint: `${AS_ISSUER}/authorize`,
|
||||
token_endpoint: `${AS_ISSUER}/token`,
|
||||
revocation_endpoint: `${AS_ISSUER}/revoke`,
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
servers: {
|
||||
// The raw spelling the transport keyed by, holding a STALE grant.
|
||||
"https://example.com": {
|
||||
activeIssuer: AS_ISSUER,
|
||||
byIssuer: {
|
||||
[AS_ISSUER]: {
|
||||
tokens: {
|
||||
access_token: "stale-a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "stale-r",
|
||||
},
|
||||
},
|
||||
},
|
||||
serverMetadata: metadata,
|
||||
},
|
||||
// The normalised spelling, holding the CURRENT grant.
|
||||
"https://example.com/": {
|
||||
activeIssuer: AS_ISSUER,
|
||||
byIssuer: {
|
||||
[AS_ISSUER]: {
|
||||
tokens: {
|
||||
access_token: "live-a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "live-r",
|
||||
},
|
||||
},
|
||||
},
|
||||
serverMetadata: metadata,
|
||||
},
|
||||
},
|
||||
idpSessions: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
||||
resetNodeOAuthStorageCache();
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
try {
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://example.com"),
|
||||
).resolves.toMatchObject({ status: "revoked" });
|
||||
const tokens = fetchSpy.mock.calls.map((call) =>
|
||||
new URLSearchParams(String(call[1]?.body)).get("token"),
|
||||
);
|
||||
// Normalised first, then the stale raw one — neither is left behind.
|
||||
expect(tokens).toEqual(["live-r", "stale-r"]);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
|
||||
const 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();
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// Keys are revoked from independently. Deduplicating them could only be
|
||||
// done by pre-reading one token, which would skip the second key's OTHER
|
||||
// issuer-bound grants along with it; a duplicate RFC 7009 request is
|
||||
// harmless (§2.2 makes an unknown token a success), a missed one is not.
|
||||
it("revokes from both keys even when they hold the same token", async () => {
|
||||
seedBothSpellings("same-r", "same-r");
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(null, { status: 500 }));
|
||||
try {
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://example.com"),
|
||||
).resolves.toMatchObject({ status: "failed" });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// A grant still live at the authorization server is what the user needs to
|
||||
// hear about; reporting the earlier success would print no warning at all.
|
||||
// One budget across both keys, so a first request that outlives it fails
|
||||
// rather than each key getting a fresh five seconds.
|
||||
it("bounds both keys with one shared budget", async () => {
|
||||
seedBothSpellings("live-r", "stale-r");
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(async () => {
|
||||
// Outlives the injected budget, so the second key is never attempted.
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
try {
|
||||
const outcome = await clearStoredAuthForRelogin("https://example.com", {
|
||||
budgetMs: 20,
|
||||
});
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome?.status === "failed" ? outcome.detail : "").toContain(
|
||||
"timed out after 20ms",
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// A plan reached with no budget left is reported rather than firing a
|
||||
// request it would immediately abandon — and that report must outrank an
|
||||
// earlier success, since the unattempted key's grant may still be live.
|
||||
it("reports exhaustion for a key it never attempted", async () => {
|
||||
seedBothSpellings("live-r", "stale-r");
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
try {
|
||||
const outcome = await clearStoredAuthForRelogin("https://example.com", {
|
||||
budgetMs: 0,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome?.status === "failed" ? outcome.detail : "").toContain(
|
||||
"budget was exhausted",
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a later failure over an earlier success", async () => {
|
||||
seedBothSpellings("live-r", "stale-r");
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }))
|
||||
.mockResolvedValue(new Response(null, { status: 500 }));
|
||||
try {
|
||||
await expect(
|
||||
clearStoredAuthForRelogin("https://example.com"),
|
||||
).resolves.toMatchObject({ status: "failed" });
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// `getTokens` parses through `OAuthTokensSchema`, so a persisted token that
|
||||
// no longer validates rejects. That must not abandon the local delete
|
||||
// `--relogin` promises — over a grant that could not have been revoked.
|
||||
it("still clears when the stored token cannot be parsed", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-bad-token-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
servers: {
|
||||
"https://example.com/mcp": {
|
||||
activeIssuer: AS_ISSUER,
|
||||
byIssuer: {
|
||||
// No `token_type` — fails OAuthTokensSchema.
|
||||
[AS_ISSUER]: { tokens: { access_token: 42 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
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.toMatchObject({ status: "failed" });
|
||||
|
||||
const blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
||||
servers: Record<string, unknown>;
|
||||
};
|
||||
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
|
||||
});
|
||||
|
||||
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");
|
||||
|
|
|
|||
227
clients/cli/__tests__/relogin-revocation.test.ts
Normal file
227
clients/cli/__tests__/relogin-revocation.test.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* `--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 AS_ISSUER = "https://as.example.com";
|
||||
const REVOKE_URL = `${AS_ISSUER}/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: {
|
||||
// Issuer-bound (SEP-2352) and matching the cached metadata: an unkeyed
|
||||
// grant records no authorization server and is deliberately refused.
|
||||
[SERVER_URL]: {
|
||||
activeIssuer: AS_ISSUER,
|
||||
byIssuer: {
|
||||
[AS_ISSUER]: {
|
||||
tokens: {
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "r",
|
||||
},
|
||||
},
|
||||
},
|
||||
serverMetadata: {
|
||||
issuer: AS_ISSUER,
|
||||
authorization_endpoint: `${AS_ISSUER}/authorize`,
|
||||
token_endpoint: `${AS_ISSUER}/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);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,14 @@ import {
|
|||
NodeOAuthStorage,
|
||||
resetNodeOAuthStorageCache,
|
||||
} from "@inspector/core/auth/node/storage-node.js";
|
||||
import {
|
||||
DEFAULT_REVOCATION_TIMEOUT_MS,
|
||||
clearAndPlanRevocation,
|
||||
executeOAuthRevocation,
|
||||
type OAuthRevocationPlan,
|
||||
type TokenRevocationOutcome,
|
||||
} from "@inspector/core/auth/revocation.js";
|
||||
import { createProxyFetch } from "@inspector/core/mcp/node/proxyFetch.js";
|
||||
|
||||
/** Same canonicalisation as CLI `normalizeServerUrl` (avoid cycles). */
|
||||
function normalizeServerUrl(serverUrl: string): string {
|
||||
|
|
@ -24,16 +32,121 @@ function normalizeServerUrl(serverUrl: string): string {
|
|||
*/
|
||||
export async function clearStoredAuthForRelogin(
|
||||
serverUrl: string | undefined,
|
||||
): Promise<void> {
|
||||
if (!serverUrl?.trim()) return;
|
||||
options?: {
|
||||
revoke?: boolean;
|
||||
/**
|
||||
* Total wall-clock budget for the revocation requests across both key
|
||||
* spellings. Defaults to {@link DEFAULT_REVOCATION_TIMEOUT_MS}; injectable
|
||||
* so the exhaustion path can be exercised without a five-second test.
|
||||
*/
|
||||
budgetMs?: number;
|
||||
},
|
||||
): Promise<TokenRevocationOutcome | undefined> {
|
||||
if (!serverUrl?.trim()) return undefined;
|
||||
const raw = serverUrl.trim();
|
||||
const normalized = normalizeServerUrl(raw);
|
||||
const storage = new NodeOAuthStorage();
|
||||
await storage.clear(raw);
|
||||
if (normalized !== raw) {
|
||||
await storage.clear(normalized);
|
||||
// RFC 7009 (#2144), ordered snapshot -> clear -> revoke. Everything the
|
||||
// requests need is read first, because the clear empties the store; but the
|
||||
// clear then runs immediately rather than behind the network. Waiting would
|
||||
// hold this process's in-memory view of a *shared*, file-backed store for up
|
||||
// to five seconds, and another CLI/TUI writing a fresh grant in that window
|
||||
// would be erased by the clear that followed.
|
||||
//
|
||||
// Both spellings are cleared, so both are planned from — a stale entry under
|
||||
// the other key is a live grant at the authorization server, and deleting it
|
||||
// locally without revoking is exactly the leak this closes. The normalised
|
||||
// key goes first because that is the precedence `findStoredServerState` reads
|
||||
// with, so the grant actually in use is the one whose outcome is reported.
|
||||
// They are deliberately not deduplicated; see `sendPlans`.
|
||||
const keys = normalized === raw ? [raw] : [normalized, raw];
|
||||
// Each key's state is taken and deleted in ONE atomic storage step, so the
|
||||
// clear is never a separate check-then-act over an already-read snapshot.
|
||||
const plans: OAuthRevocationPlan[] = [];
|
||||
for (const key of keys) {
|
||||
plans.push(
|
||||
await clearAndPlanRevocation({
|
||||
serverUrl: key,
|
||||
storage,
|
||||
enabled: options?.revoke !== false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Drop the in-process singleton so the next connect cannot reuse a cleared
|
||||
// entry from the NodeOAuthStorage cache.
|
||||
resetNodeOAuthStorageCache();
|
||||
|
||||
return options?.revoke === false
|
||||
? undefined
|
||||
: sendPlans(plans, options?.budgetMs ?? DEFAULT_REVOCATION_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send each plan's requests, in order, and report the outcome that matters most.
|
||||
*
|
||||
* Both key spellings are cleared, so both are planned from: a stale entry under
|
||||
* the other spelling is still a live grant at the authorization server, and
|
||||
* deleting it locally without revoking is the leak this whole change closes.
|
||||
*
|
||||
* There is deliberately **no** cross-key deduplication. It looked cheap — the
|
||||
* two keys are usually two spellings of one server — but it can only be done by
|
||||
* pre-reading a single token, and a plan enumerates every issuer slot under a
|
||||
* key. So a shared *active* token would have skipped the second key entirely,
|
||||
* taking any additional issuer-bound grant under it with the local delete. A
|
||||
* duplicate RFC 7009 request is harmless (§2.2 makes an unknown token a
|
||||
* success), which is a much better trade than a missed one.
|
||||
*
|
||||
* One budget across both plans, for the same reason a plan shares one across
|
||||
* its grants: two keys would otherwise double the wait.
|
||||
*
|
||||
* Reporting prefers a **failure** over any success — a grant still live at the
|
||||
* authorization server is what the user needs to hear about, and a success
|
||||
* would otherwise silence the warning. Failing that it is the first plan's
|
||||
* outcome that was not "nothing to do", which with the keys in
|
||||
* `findStoredServerState` precedence means the grant the CLI would actually
|
||||
* have connected with. When no key held a token, the last "nothing to do"
|
||||
* answer is returned so the caller can still tell "no tokens" from "this
|
||||
* authorization server advertises no revocation endpoint".
|
||||
*/
|
||||
async function sendPlans(
|
||||
plans: OAuthRevocationPlan[],
|
||||
budgetMs: number,
|
||||
): Promise<TokenRevocationOutcome | undefined> {
|
||||
const fetchFn = createProxyFetch() ?? fetch;
|
||||
const deadlineAt = Date.now() + budgetMs;
|
||||
let reported: TokenRevocationOutcome | undefined;
|
||||
let lastSkip: TokenRevocationOutcome | undefined;
|
||||
for (const plan of plans) {
|
||||
const remainingMs = deadlineAt - Date.now();
|
||||
// A plan that already knows its answer needs no network, so the budget is
|
||||
// irrelevant to it. Synthesising exhaustion here would warn that a grant
|
||||
// may still be live when the key held no grant at all — a false alarm, and
|
||||
// one that outranks the real outcome under the failure-first rule below.
|
||||
const needsNetwork = plan.outcome === undefined;
|
||||
if (needsNetwork && remainingMs <= 0) {
|
||||
// Overrides an earlier success rather than deferring to it: this key's
|
||||
// grant may still be live at the authorization server, and that is the
|
||||
// thing the user needs to hear about. Same failure-first rule as below.
|
||||
const exhausted: TokenRevocationOutcome = {
|
||||
status: "failed",
|
||||
detail: `the ${budgetMs}ms revocation budget was exhausted before "${plan.serverUrl}" was attempted`,
|
||||
};
|
||||
if (reported?.status !== "failed") reported = exhausted;
|
||||
continue;
|
||||
}
|
||||
const outcome = await executeOAuthRevocation(plan, {
|
||||
fetchFn,
|
||||
timeoutMs: needsNetwork ? remainingMs : undefined,
|
||||
});
|
||||
if (outcome.status === "skipped" && outcome.reason === "no_tokens") {
|
||||
lastSkip = outcome;
|
||||
continue;
|
||||
}
|
||||
if (outcome.status === "failed") {
|
||||
if (reported?.status !== "failed") reported = outcome;
|
||||
} else {
|
||||
reported ??= outcome;
|
||||
}
|
||||
}
|
||||
return reported ?? lastSkip;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ async function callMethod(
|
|||
callbackUrlConfig: RunnerOAuthCallbackConfig,
|
||||
storedAuthOnly: boolean,
|
||||
relogin: boolean,
|
||||
revoke: boolean,
|
||||
): Promise<void> {
|
||||
// Clear after parse-time validation so a bad flag combo never deletes store
|
||||
// entries. Deletes the shared URL-keyed OAuth entry (not "ignore for this run").
|
||||
|
|
@ -117,7 +118,18 @@ async function callMethod(
|
|||
"--relogin requires an HTTP/SSE server URL (no OAuth store entry for stdio)",
|
||||
);
|
||||
}
|
||||
await clearStoredAuthForRelogin(serverConfig.url);
|
||||
// RFC 7009 (#2144). The flag and the per-server setting are both opt-outs,
|
||||
// so either one turns the revocation off; neither can turn it on for the
|
||||
// other. Reported rather than thrown — `--relogin` is a local delete and
|
||||
// must not start failing because an authorization server is unreachable.
|
||||
const revocation = await clearStoredAuthForRelogin(serverConfig.url, {
|
||||
revoke: revoke && serverSettings?.oauthRevokeOnClear !== false,
|
||||
});
|
||||
if (revocation?.status === "failed") {
|
||||
process.stderr.write(
|
||||
`Warning: could not revoke the OAuth grant at the authorization server (${revocation.detail}); it may still be valid there.\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Version comes from the single source of truth — the root package.json —
|
||||
|
|
@ -592,6 +604,7 @@ type ParseResult =
|
|||
callbackUrl?: string;
|
||||
storedAuthOnly?: boolean;
|
||||
relogin?: boolean;
|
||||
revoke?: boolean;
|
||||
}
|
||||
// Short-circuit modes (`--list-stored-auth`, `--print-handoff`) do their own
|
||||
// output and need no server connection; runCli returns immediately.
|
||||
|
|
@ -787,6 +800,10 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
|
|||
"--relogin",
|
||||
"Delete stored OAuth for this server URL from the shared store before connect (HTTP/SSE URL keys only); interactive login runs only if the server requires auth. Rejected for stdio (no URL-keyed store entry)",
|
||||
)
|
||||
.option(
|
||||
"--no-revoke",
|
||||
"Requires --relogin. Skips the RFC 7009 revocation request that would otherwise end the grant at the authorization server when the local state is deleted. Also skipped when the server entry sets oauth.revokeOnClear to false.",
|
||||
)
|
||||
.option(
|
||||
"--wait-for-auth <sec>",
|
||||
"Poll the OAuth state file until a token for --server-url appears (or the timeout elapses), then proceed as if --use-stored-auth were set. Use after handing off to a human to complete OAuth in a browser.",
|
||||
|
|
@ -842,6 +859,7 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
|
|||
useStoredAuth?: boolean;
|
||||
storedAuthOnly?: boolean;
|
||||
relogin?: boolean;
|
||||
revoke?: boolean;
|
||||
waitForAuth?: number;
|
||||
listStoredAuth?: boolean;
|
||||
printHandoff?: boolean;
|
||||
|
|
@ -871,6 +889,15 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
|
|||
}
|
||||
}
|
||||
|
||||
// `--no-revoke` only means anything alongside `--relogin` — it suppresses the
|
||||
// RFC 7009 request that clear makes. Accepted on its own it is inert, and
|
||||
// worse than inert: it reads as "this run will not revoke anything", which is
|
||||
// true only because nothing was going to be cleared. Rejected here, ahead of
|
||||
// the short-circuit returns, for the same reason `--strict` is (#2144).
|
||||
if (options.revoke === false && !options.relogin) {
|
||||
throw new Error("--no-revoke requires --relogin (it has no other effect).");
|
||||
}
|
||||
|
||||
// `--strict` is checked HERE, ahead of every short-circuit return below
|
||||
// (`--list-stored-auth`, `--print-handoff`, `servers/list`, `servers/show`),
|
||||
// rather than beside the other method-shaped validations further down. Those
|
||||
|
|
@ -1134,6 +1161,9 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
|
|||
callbackUrl: options.callbackUrl,
|
||||
storedAuthOnly: options.storedAuthOnly === true,
|
||||
relogin: options.relogin === true,
|
||||
// Commander's `--no-revoke` defaults this to true; only an explicit
|
||||
// `--no-revoke` makes it false.
|
||||
revoke: options.revoke !== false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1152,6 +1182,7 @@ export async function runCli(argv?: string[]): Promise<void> {
|
|||
callbackUrl,
|
||||
storedAuthOnly,
|
||||
relogin,
|
||||
revoke,
|
||||
} = parsed;
|
||||
const clientConfig = await loadRunnerClientConfig({ clientConfigPath });
|
||||
// A bad --callback-url / MCP_OAUTH_CALLBACK_URL is a *usage* error, but its
|
||||
|
|
@ -1181,5 +1212,6 @@ export async function runCli(argv?: string[]): Promise<void> {
|
|||
callbackUrlConfig,
|
||||
storedAuthOnly === true,
|
||||
relogin === true,
|
||||
revoke !== false,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue