cli: stored-auth injection honors refresh_token, not just access_token (#1665)

Closes #1665

`--use-stored-auth` previously injected the stored access token blindly, so an
expired token failed even with a valid refresh_token on disk. Now, when a
`refresh_token` is stored, the CLI runs the OAuth refresh grant and injects the
fresh access token, persisting the rotation.

- New exported `refreshStoredAuthToken(serverUrl, statePath, deps?)`: reuses the
  SDK's `refreshAuthorization` (not a hand-rolled token request) with the stored
  `clientInformation` + `serverMetadata` (discovered via core
  `getAuthorizationServerUrl` + SDK `discoverAuthorizationServerMetadata` when
  the metadata wasn't persisted). Rotated tokens are written back through
  `serializeOAuthPersistBlob` under the same `{servers,idpSessions}` key, so web
  and CLI stay consistent. Missing refresh_token/clientInformation or a failed
  grant throw `CliExitCodeError(AUTH_REQUIRED)` with a clear message + envelope
  code (`no_stored_token` / `refresh_failed`).
- `--use-stored-auth` prefers refresh when a refresh_token is present (covers
  both absent and expired access tokens — the stored blob has no expiry, so the
  refresh token is the durable credential); falls back to injecting the stored
  access token otherwise (unchanged).
- `deps` are injectable so the refresh/discovery are unit-tested without a live
  token endpoint; an e2e test drives the real SDK path against a mock token
  endpoint and asserts the refreshed token is injected + the rotation persisted.
- CLI README updated (removed the "blind injection" caveat).

CLI coverage gate green (cli.ts 100/92/100/100).

Part of the #1579 decomposition (Wave 4, CLI lane).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
This commit is contained in:
cliffhall 2026-07-11 23:11:52 -04:00
commit cdf124106e

View file

@ -28,7 +28,21 @@ import type { JsonValue } from "@inspector/core/mcp/index.js";
import { extractAppInfo } from "@inspector/core/mcp/apps.js";
import type { AppInfo } from "@inspector/core/mcp/apps.js";
import { getStateFilePath } from "@inspector/core/auth/node/storage-node.js";
import { parseOAuthPersistBlob } from "@inspector/core/auth/oauth-persist.js";
import {
parseOAuthPersistBlob,
serializeOAuthPersistBlob,
type OAuthPersistSnapshot,
} from "@inspector/core/auth/oauth-persist.js";
import { getAuthorizationServerUrl } from "@inspector/core/auth/discovery.js";
import {
refreshAuthorization,
discoverAuthorizationServerMetadata,
} from "@modelcontextprotocol/sdk/client/auth.js";
import type {
OAuthClientInformation,
OAuthMetadata,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { CliExitCodeError, EXIT_CODES } from "./error-handler.js";
import {
ConsoleNavigation,
@ -469,26 +483,59 @@ export function normalizeServerUrl(serverUrl: string): string {
}
}
/** The subset of a stored server's OAuth state the CLI reads/refreshes. */
type StoredServerState = {
tokens?: OAuthTokens;
clientInformation?: OAuthClientInformation;
serverMetadata?: OAuthMetadata;
};
/** The stored-server map shape the CLI reads out of the OAuth state file. */
type StoredServers = Record<string, { tokens?: { access_token?: string } }>;
type StoredServers = Record<string, StoredServerState>;
/**
* Read the OAuth state file directly (bypassing the Zustand store cache) so
* each call sees the current on-disk state — required for `--wait-for-auth`
* polling. Returns the `servers` map, or an empty object when the file is
* absent or unreadable. Uses the shared {@link parseOAuthPersistBlob} so both
* the plain `{servers,idpSessions}` and legacy `{state,version}` layouts are
* polling. Returns the full snapshot, or an empty one when the file is absent
* or unreadable. Uses the shared {@link parseOAuthPersistBlob} so both the
* plain `{servers,idpSessions}` and legacy `{state,version}` layouts are
* accepted, matching whatever the web backend wrote.
*/
async function readOAuthServers(statePath: string): Promise<StoredServers> {
async function readOAuthSnapshot(
statePath: string,
): Promise<OAuthPersistSnapshot> {
const { readFile } = await import("node:fs/promises");
try {
const text = await readFile(statePath, "utf8");
const snapshot = parseOAuthPersistBlob(text);
return (snapshot?.servers as StoredServers | undefined) ?? {};
if (snapshot) return snapshot;
} catch {
return {};
// Absent/unreadable/malformed → fall through to the empty snapshot below.
}
return { servers: {}, idpSessions: {} };
}
/**
* Read just the `servers` map. Thin wrapper over {@link readOAuthSnapshot} for
* the read-only lookups (`findStoredToken`, `--wait-for-auth`, key listing).
*/
async function readOAuthServers(statePath: string): Promise<StoredServers> {
return (await readOAuthSnapshot(statePath)).servers as StoredServers;
}
/**
* Look up a stored server's OAuth state, trying the URL-normalised key first
* (how the web store writes it) and the raw string second. Returns the matched
* key so a write-back updates the same entry.
*/
function findStoredServerState(
servers: StoredServers,
serverUrl: string,
): { key: string; state: StoredServerState } | undefined {
const normalized = normalizeServerUrl(serverUrl);
if (servers[normalized])
return { key: normalized, state: servers[normalized] };
if (servers[serverUrl]) return { key: serverUrl, state: servers[serverUrl] };
return undefined;
}
/**
@ -499,11 +546,89 @@ function findStoredToken(
servers: StoredServers,
serverUrl: string,
): string | undefined {
const key = normalizeServerUrl(serverUrl);
return (
servers[key]?.tokens?.access_token ??
servers[serverUrl]?.tokens?.access_token
);
return findStoredServerState(servers, serverUrl)?.state.tokens?.access_token;
}
/**
* Injectable dependencies for {@link refreshStoredAuthToken}, so the refresh
* grant + auth-server discovery can be faked in unit tests without standing up
* a real OAuth token endpoint. Both default to the SDK implementations.
*/
export interface RefreshStoredAuthDeps {
refresh?: typeof refreshAuthorization;
discover?: typeof discoverAuthorizationServerMetadata;
}
/**
* Run the OAuth `refresh_token` grant for a stored server and persist the
* rotated tokens back to `statePath` (same `{servers,idpSessions}` shape the
* web backend writes), returning the fresh access token.
*
* Reuses the SDK's {@link refreshAuthorization} (not a hand-rolled token
* request) with the stored `clientInformation` + `serverMetadata`; when the
* metadata wasn't persisted it is discovered from the resolved authorization
* server. A missing refresh token or client information, or a failed grant,
* throws {@link CliExitCodeError} with {@link EXIT_CODES.AUTH_REQUIRED} so the
* caller exits with the documented code and a clear message.
*/
export async function refreshStoredAuthToken(
serverUrl: string,
statePath: string,
deps: RefreshStoredAuthDeps = {},
): Promise<string> {
const refresh = deps.refresh ?? refreshAuthorization;
const discover = deps.discover ?? discoverAuthorizationServerMetadata;
const snapshot = await readOAuthSnapshot(statePath);
const servers = snapshot.servers as StoredServers;
const found = findStoredServerState(servers, serverUrl);
const refreshToken = found?.state.tokens?.refresh_token;
const clientInformation = found?.state.clientInformation;
if (!found || !refreshToken) {
throw new CliExitCodeError(
EXIT_CODES.AUTH_REQUIRED,
`No stored refresh token for ${normalizeServerUrl(serverUrl)} in ${statePath}. Complete the OAuth flow in the web inspector first.`,
{ code: "no_stored_token", url: serverUrl },
);
}
if (!clientInformation) {
throw new CliExitCodeError(
EXIT_CODES.AUTH_REQUIRED,
`Stored auth for ${normalizeServerUrl(serverUrl)} has a refresh token but no client information; cannot refresh. Re-authorize in the web inspector.`,
{ code: "no_stored_token", url: serverUrl },
);
}
const authServerUrl = found.state.serverMetadata?.issuer
? new URL(found.state.serverMetadata.issuer)
: getAuthorizationServerUrl(serverUrl);
const metadata =
found.state.serverMetadata ?? (await discover(authServerUrl)) ?? undefined;
let tokens: OAuthTokens;
try {
tokens = await refresh(authServerUrl, {
metadata,
clientInformation,
refreshToken,
resource: new URL(serverUrl),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new CliExitCodeError(
EXIT_CODES.AUTH_REQUIRED,
`Failed to refresh the stored OAuth token for ${normalizeServerUrl(serverUrl)}: ${message}. Re-authorize in the web inspector.`,
{ code: "refresh_failed", url: serverUrl },
);
}
// Persist the rotated tokens back under the same key, preserving every other
// server entry and the idpSessions block, so web and CLI stay consistent.
servers[found.key] = { ...found.state, tokens };
const { writeFile } = await import("node:fs/promises");
await writeFile(statePath, serializeOAuthPersistBlob(snapshot), "utf8");
return tokens.access_token;
}
/**
@ -934,11 +1059,13 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
}
// Read the OAuth state file directly so the lookup is normalised the same
// way the web inspector wrote it (`new URL().href`), and so `--wait-for-
// auth` sees fresh on-disk state on each poll. Header injection is the
// prototype path — only the access token is injected, blindly; a stale one
// surfaces as HTTP 401 → exit 3 (auth_required). Follow-up: when the stored
// `tokens.refresh_token` is present and the access token is expired, mint a
// fresh one instead of injecting the stale token.
// auth` sees fresh on-disk state on each poll. When a `refresh_token` is
// stored, the CLI runs the SDK refresh grant and injects the fresh access
// token (persisting the rotation) rather than blindly injecting a possibly-
// stale stored access token (#1665) — the stored blob carries no expiry, so
// the refresh token is the durable credential. Without a refresh token it
// falls back to injecting the stored access token; a stale one surfaces as
// HTTP 401 → exit 3 (auth_required).
let token: string;
if (options.waitForAuth !== undefined) {
token = await waitForStoredToken(
@ -948,18 +1075,25 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
);
} else {
const servers = await readOAuthServers(oauthStatePath);
const found = findStoredToken(servers, options.serverUrl);
if (!found) {
const key = normalizeServerUrl(options.serverUrl);
const stored = Object.keys(servers);
throw new CliExitCodeError(
EXIT_CODES.AUTH_REQUIRED,
`No stored OAuth token for ${key} in ${oauthStatePath}. Complete the OAuth flow in the web inspector first.` +
(stored.length > 0 ? ` Stored keys: ${stored.join(", ")}.` : ""),
{ code: "no_stored_token", url: options.serverUrl },
);
const stored = findStoredServerState(servers, options.serverUrl);
if (stored?.state.tokens?.refresh_token) {
token = await refreshStoredAuthToken(options.serverUrl, oauthStatePath);
} else {
const found = findStoredToken(servers, options.serverUrl);
if (!found) {
const key = normalizeServerUrl(options.serverUrl);
const storedKeys = Object.keys(servers);
throw new CliExitCodeError(
EXIT_CODES.AUTH_REQUIRED,
`No stored OAuth token for ${key} in ${oauthStatePath}. Complete the OAuth flow in the web inspector first.` +
(storedKeys.length > 0
? ` Stored keys: ${storedKeys.join(", ")}.`
: ""),
{ code: "no_stored_token", url: options.serverUrl },
);
}
token = found;
}
token = found;
}
serverOptions.headers = {
...(serverOptions.headers ?? {}),