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
2 changed files with 8524 additions and 1598 deletions
|
|
@ -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