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:
parent
a029e42a19
commit
cdf124106e
1 changed files with 365 additions and 34 deletions
|
|
@ -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 ?? {}),
|
||||
|
|
|
|||
Loading…
Reference in a new issue