inspector/clients/cli/src/cliOAuth.ts
Bob Dickinson 33e88eac9f
CLI: catalog listing, auth flags, and OAuth browser UX (#1782)
* feat(cli): catalog listing, auth flags, and OAuth browser UX

Add servers/list and servers/show, --relogin / --stored-auth-only, browser
OAuth navigation with OSC 8 links, and extract method handlers under
clients/cli/src/handlers. Tracks #1781.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): address #1782 review — relogin clear, stored-auth-only, docs

Clear both raw and normalized OAuth keys on --relogin; check the shared
store before bailing under --stored-auth-only mid-session; restore the
tests README link target; document servers/show redaction limits; keep
mcpi groundwork with TODO(#1432) markers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): gate OAuth browser open and harden #1782 review follow-ups

Arm auto-open only for CLI-owned interactive OAuth, honor MCP_AUTO_OPEN_ENABLED,
move --relogin clear after validation, skip keychain on servers/list, and cover
secret redaction after rehydrate.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): silence disarmed OAuth URLs and round-3 review nits

Suppress authorize URL print when navigation is disarmed or
--stored-auth-only; document MCP_AUTO_OPEN_ENABLED; redact Cookie/auth
headers; keep servers/show unit tests off the OS keychain.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cli): cover withArmedAutoOpen for the ≥90 branch gate

CI failed on cliOAuth.ts branch coverage after the auto-open arming
helper landed; exercise the armed path through runCliInteractiveOAuth.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): mid-RPC unauthorized recovery and clear round-4 nits

Mirror connect's UnauthorizedError path in withCliAuthRecoveryRetry, let
MCP_AUTO_OPEN_ENABLED=true force open off-TTY, mock open-url in the
in-process runner, reject --relogin on catalog methods, restore the
__tests__ README inventory, and narrow OAuth helpers to structural client types.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): reconnect after mid-RPC OAuth and finish round-5 nits

Reconnect before retrying after unauthorized recovery, guard non-OAuth
configs, decouple forceAutoOpen, move open-url mock to vitest setupFiles,
and reject --relogin with stored-auth short-circuits.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): fail fast on non-TTY interactive OAuth and round-6 nits

Skip interactive OAuth without a TTY unless MCP_AUTO_OPEN_ENABLED=true;
document --stored-auth-only for CI; redact settings.metadata; reject
--relogin for stdio; clarify AuthRecoveryRequired reconnect comments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): admit interactive OAuth when stdin is a TTY (round-7)

Gate on stdin||stderr so `2>&1 | tee` still works; fold confirm/auto-open/
TTY into CliOAuthConnectOptions; rename authRequiredFailure; document
MCP_AUTO_OPEN_ENABLED=true as non-TTY admit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): fail fast on step-up without stdin (round-8)

Decline [y/N] on readline EOF/close, require a stdin TTY for the default
step-up confirmer (force-open admit is not enough), document stderr-only
browser auto-open vs stdin||stderr flow admit, and drop the alias wrapper.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): allow piped step-up answers; bound silent pipes (round-9)

Drop the hard stdin-TTY gate that broke `echo y | …`. Keep EOF decline and
add a short non-TTY timeout so an open pipe that never writes cannot hang.
Clarify isTTY as admit-only; extract the readline mock factory.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): keep partial piped step-up answers (round-10)

Capture readline `line` before close so `printf y` without a newline is
not treated as decline; timeout is auth_required with a distinct message;
document the 5s bound and post-confirm callback wait; pin the TTY no-timeout arm.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): tighten step-up timeout messaging and TTY test (round-11)

Make the TTY no-timeout assertion use a settled flag (the Promise.race
check was vacuous), say "timed out" instead of "declined" on the pipe
bound, and document that piped answers need a newline or stdin close.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:55:25 -04:00

413 lines
14 KiB
TypeScript

import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
import {
AuthRecoveryRequiredError,
isStandardOAuthStepUp as isCoreStandardOAuthStepUp,
isUnauthorizedError,
stepUpConfirmMessage,
stepUpInsufficientScopeMessage,
MutableRedirectUrlProvider,
} from "@inspector/core/auth/index.js";
import {
createOAuthCallbackServer,
runRunnerInteractiveOAuth,
} from "@inspector/core/auth/node/index.js";
import type { RunnerInteractiveOAuthClient } from "@inspector/core/auth/node/runner-interactive-oauth.js";
import type { RunnerOAuthCallbackConfig } from "@inspector/core/auth/node/runner-oauth-callback.js";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import { isOAuthCapableServerConfig } from "@inspector/core/client/runner.js";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
import { createInterface } from "node:readline/promises";
import { CliExitCodeError, EXIT_CODES } from "./error-handler.js";
import {
isCliAutoOpenForced,
type CliOAuthAutoOpenControl,
} from "./cli-oauth-navigation.js";
/** Client surface needed for connect + mid-RPC OAuth recovery. */
export type CliOAuthClient = RunnerInteractiveOAuthClient & {
connect(): Promise<void>;
disconnect(): Promise<void>;
};
/** Default bound for a non-TTY step-up [y/N] that never sends a line. */
export const STEP_UP_PIPE_TIMEOUT_MS = 5_000;
export type CliOAuthConnectOptions = {
/**
* When true, never open interactive OAuth / step-up prompts. Use the shared
* store if it can satisfy the challenge; otherwise fail with AUTH_REQUIRED.
*/
storedAuthOnly?: boolean;
/**
* Arms browser auto-open only around the CLI-owned interactive OAuth flow
* (callback server listening). See {@link createCliOAuthNavigation}.
*/
autoOpenControl?: CliOAuthAutoOpenControl;
/**
* Override “human present” detection for interactive-OAuth admit gating
* (tests and programmatic callers). When omitted, production uses
* `stdin.isTTY || stderr.isTTY` so `2>&1 | tee` still works (stdin remains a
* TTY). Does **not** gate the step-up [y/N] confirmer — that accepts piped
* answers and bounds silent pipes via {@link stepUpPromptTimeoutMs}.
*/
isTTY?: boolean;
/**
* Override the step-up [y/N] confirmer (tests and programmatic callers).
* When omitted, prompts on stderr and reads from stdin.
*/
confirmStepUp?: () => Promise<boolean>;
/**
* Bound (ms) for the default step-up confirmer when stdin is not a TTY, so a
* pipe that never writes cannot hang forever. Ignored on a TTY stdin (humans
* may pause). When set explicitly (including in tests), always applied.
* Default: {@link STEP_UP_PIPE_TIMEOUT_MS}.
*/
stepUpPromptTimeoutMs?: number;
};
function authRequiredFailure(message: string): never {
throw new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, message, {
code: "auth_required",
});
}
/**
* Interactive OAuth waits up to 15 minutes on the loopback callback. Admit the
* flow when a human is present (`stdin` or `stderr` is a TTY — so `2>&1 | tee`
* still works), or when `MCP_AUTO_OPEN_ENABLED=true` (explicit non-TTY /
* automation opt-in, which also force-opens a browser).
*
* Browser auto-open stays stderr-only — see {@link createCliOAuthNavigation}.
* Step-up [y/N] accepts piped stdin (`echo y | …`); EOF and silent pipes are
* bounded in the default confirmer (close race + {@link STEP_UP_PIPE_TIMEOUT_MS}).
*/
export function assertInteractiveOAuthAllowed(
options?: Pick<CliOAuthConnectOptions, "isTTY">,
): void {
const humanPresent =
options?.isTTY !== undefined
? options.isTTY
: process.stdin.isTTY === true || process.stderr.isTTY === true;
if (humanPresent || isCliAutoOpenForced()) return;
authRequiredFailure(
"Interactive OAuth requires a TTY on stdin or stderr (or MCP_AUTO_OPEN_ENABLED=true). For CI/non-interactive runs use --stored-auth-only.",
);
}
/** Standard-OAuth step-up (not EMA silent re-mint). */
export function isStandardOAuthStepUp(
challenge: AuthChallenge,
settings?: InspectorServerSettings,
): boolean {
return isCoreStandardOAuthStepUp(challenge, {
enterpriseManaged: settings?.enterpriseManaged,
});
}
/**
* Read [y/N] from stdin. Piped answers work when newline-terminated or when
* stdin closes (`echo y | …`, `printf y | …`). On EOF with no line
* (`< /dev/null`), decline. A non-TTY stdin that never sends a line within
* {@link STEP_UP_PIPE_TIMEOUT_MS} fails with `auth_required` (timed out —
* distinct from an explicit **N**).
*
* Partial last lines without a trailing newline are captured via the `line`
* event — on stream end readline emits the buffer then `close` without
* settling `question()`, so close alone would otherwise silently decline.
* An open pipe that writes `y` without `\n` or EOF never flushes that buffer.
*/
async function confirmStepUpFromStdin(timeoutMs?: number): Promise<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stderr });
const applyTimeoutMs =
timeoutMs !== undefined
? timeoutMs
: process.stdin.isTTY === true
? undefined
: STEP_UP_PIPE_TIMEOUT_MS;
try {
const answer = await new Promise<string>((resolve, reject) => {
let settled = false;
let lastLine: string | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
function cleanup(): void {
if (timer !== undefined) clearTimeout(timer);
rl.removeListener("close", onClose);
rl.removeListener("line", onLine);
}
function finish(value: string): void {
if (settled) return;
settled = true;
cleanup();
resolve(value);
}
function finishTimeout(): void {
if (settled) return;
settled = true;
cleanup();
reject(
new CliExitCodeError(
EXIT_CODES.AUTH_REQUIRED,
`Step-up authorization timed out (no answer on stdin within ${String(applyTimeoutMs)}ms). For CI/non-interactive runs use --stored-auth-only.`,
{ code: "auth_required" },
),
);
}
function onLine(line: string): void {
lastLine = line;
}
function onClose(): void {
finish(lastLine ?? "n");
}
rl.on("line", onLine);
rl.once("close", onClose);
if (applyTimeoutMs !== undefined) {
timer = setTimeout(finishTimeout, applyTimeoutMs);
}
void rl.question("").then(
(line) => finish(line),
() => finish(lastLine ?? "n"),
);
});
const normalized = answer.trim().toLowerCase();
return normalized === "y" || normalized === "yes";
} finally {
rl.close();
}
}
async function promptStepUpConfirm(
challenge: AuthChallenge,
confirmStepUp: () => Promise<boolean>,
): Promise<boolean> {
process.stderr.write(`${stepUpConfirmMessage(challenge)}\n`);
process.stderr.write("Proceed with step-up authorization? [y/N] ");
return confirmStepUp();
}
async function withArmedAutoOpen<T>(
control: CliOAuthAutoOpenControl | undefined,
fn: () => Promise<T>,
): Promise<T> {
if (!control) return fn();
control.armed = true;
try {
return await fn();
} finally {
control.armed = false;
}
}
export async function runCliInteractiveOAuth(
client: RunnerInteractiveOAuthClient,
redirectUrlProvider: MutableRedirectUrlProvider,
callbackUrlConfig: RunnerOAuthCallbackConfig,
options?: {
authorizationUrl?: URL;
authChallenge?: AuthChallenge;
autoOpenControl?: CliOAuthAutoOpenControl;
},
): Promise<void> {
const result = await withArmedAutoOpen(options?.autoOpenControl, () =>
runRunnerInteractiveOAuth({
client,
redirectUrlProvider,
callbackListen: callbackUrlConfig,
createCallbackServer: createOAuthCallbackServer,
authorizationUrl: options?.authorizationUrl,
authChallenge: options?.authChallenge,
}),
);
if (result.kind === "insufficient_scope") {
throw new Error(stepUpInsufficientScopeMessage(result.challenge));
}
if (result.kind === "success") {
process.stderr.write("Authorization complete.\n");
}
}
export async function handleCliAuthRecoveryRequired(
client: RunnerInteractiveOAuthClient,
error: AuthRecoveryRequiredError,
redirectUrlProvider: MutableRedirectUrlProvider,
callbackUrlConfig: RunnerOAuthCallbackConfig,
serverSettings?: InspectorServerSettings,
options?: CliOAuthConnectOptions,
): Promise<void> {
const confirmStepUp =
options?.confirmStepUp ??
(() => confirmStepUpFromStdin(options?.stepUpPromptTimeoutMs));
if (isStandardOAuthStepUp(error.authChallenge, serverSettings)) {
if (await client.checkAuthChallengeSatisfied(error.authChallenge)) {
return;
}
assertInteractiveOAuthAllowed(options);
const proceed = await promptStepUpConfirm(
error.authChallenge,
confirmStepUp,
);
if (!proceed) {
throw new Error("Step-up authorization declined.");
}
} else if (await client.checkAuthChallengeSatisfied(error.authChallenge)) {
return;
} else {
assertInteractiveOAuthAllowed(options);
}
await runCliInteractiveOAuth(client, redirectUrlProvider, callbackUrlConfig, {
authorizationUrl: error.authorizationUrl,
autoOpenControl: options?.autoOpenControl,
...(error.authChallenge.reason === "insufficient_scope" && {
authChallenge: error.authChallenge,
}),
});
}
export async function connectInspectorWithOAuth(
inspectorClient: CliOAuthClient,
serverConfig: MCPServerConfig,
redirectUrlProvider: MutableRedirectUrlProvider,
callbackUrlConfig: RunnerOAuthCallbackConfig,
serverSettings?: InspectorServerSettings,
options?: CliOAuthConnectOptions,
): Promise<void> {
try {
await inspectorClient.connect();
} catch (err) {
if (!isOAuthCapableServerConfig(serverConfig)) {
throw err;
}
if (err instanceof AuthRecoveryRequiredError) {
// Under --stored-auth-only, give the store one chance then bail — avoid
// calling handle (which would re-check) on the failure path.
if (options?.storedAuthOnly) {
if (
await inspectorClient.checkAuthChallengeSatisfied(err.authChallenge)
) {
await inspectorClient.connect();
return;
}
authRequiredFailure(
err.message ||
"Authentication required and --stored-auth-only is set; refusing interactive OAuth.",
);
}
await handleCliAuthRecoveryRequired(
inspectorClient,
err,
redirectUrlProvider,
callbackUrlConfig,
serverSettings,
options,
);
// Belt-and-braces: this branch never disconnects today, so connect() is
// usually a no-op (already connected). Fresh tokens are picked up from
// storage per request; keep the call if handle later gains a disconnect.
await inspectorClient.connect();
return;
}
if (isUnauthorizedError(err)) {
if (options?.storedAuthOnly) {
authRequiredFailure(
err instanceof Error
? err.message
: "Authentication required and --stored-auth-only is set; refusing interactive OAuth.",
);
}
assertInteractiveOAuthAllowed(options);
await inspectorClient.disconnect().catch(() => {});
await runCliInteractiveOAuth(
inspectorClient,
redirectUrlProvider,
callbackUrlConfig,
{ autoOpenControl: options?.autoOpenControl },
);
await inspectorClient.connect();
return;
}
throw err;
}
}
/**
* Run `fn` once; on auth recovery errors, complete interactive OAuth,
* reconnect, and retry `fn` a single time. Mirrors
* {@link connectInspectorWithOAuth}: handles both
* {@link AuthRecoveryRequiredError} and plain unauthorized errors, and skips
* OAuth machinery for non-OAuth-capable server configs.
*/
export async function withCliAuthRecoveryRetry<T>(
inspectorClient: CliOAuthClient,
serverConfig: MCPServerConfig,
redirectUrlProvider: MutableRedirectUrlProvider,
callbackUrlConfig: RunnerOAuthCallbackConfig,
serverSettings: InspectorServerSettings | undefined,
fn: () => Promise<T>,
options?: CliOAuthConnectOptions,
): Promise<T> {
try {
return await fn();
} catch (err) {
if (!isOAuthCapableServerConfig(serverConfig)) {
throw err;
}
if (err instanceof AuthRecoveryRequiredError) {
// Satisfied-check lives in handleCliAuthRecoveryRequired for the
// interactive path; under --stored-auth-only check once here then bail.
if (options?.storedAuthOnly) {
if (
await inspectorClient.checkAuthChallengeSatisfied(err.authChallenge)
) {
return await fn();
}
authRequiredFailure(
err.message ||
"Authentication required and --stored-auth-only is set; refusing interactive OAuth.",
);
}
await handleCliAuthRecoveryRequired(
inspectorClient,
err,
redirectUrlProvider,
callbackUrlConfig,
serverSettings,
options,
);
// Belt-and-braces: this branch never disconnects today, so connect() is
// usually a no-op (already connected). See connectInspectorWithOAuth.
await inspectorClient.connect();
process.stderr.write("Authorization complete. Retrying…\n");
return await fn();
}
if (isUnauthorizedError(err)) {
if (options?.storedAuthOnly) {
authRequiredFailure(
err instanceof Error
? err.message
: "Authentication required and --stored-auth-only is set; refusing interactive OAuth.",
);
}
assertInteractiveOAuthAllowed(options);
await inspectorClient.disconnect().catch(() => {});
await runCliInteractiveOAuth(
inspectorClient,
redirectUrlProvider,
callbackUrlConfig,
{ autoOpenControl: options?.autoOpenControl },
);
// Load-bearing: disconnect() above closed the session.
// connect() is a no-op when already connected.
await inspectorClient.connect();
process.stderr.write("Authorization complete. Retrying…\n");
return await fn();
}
throw err;
}
}