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
3 changed files with 365 additions and 34 deletions
|
|
@ -193,14 +193,14 @@ For the common case where OAuth was already completed in the **web inspector on
|
|||
|
||||
| Option | Description |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--use-stored-auth` | Read the stored access token for `--server-url` and inject it as `Authorization: Bearer`. Exits `3` (`no_stored_token`) — listing the stored keys — when no token matches. Requires `--server-url`. |
|
||||
| `--use-stored-auth` | Read the stored auth for `--server-url` and inject `Authorization: Bearer`. When a `refresh_token` is stored, the CLI runs the OAuth refresh grant first and injects the **fresh** access token (persisting the rotation); otherwise it injects the stored access token. Exits `3` (`no_stored_token`) — listing the stored keys — when nothing matches. Requires `--server-url`. |
|
||||
| `--wait-for-auth <sec>` | Poll the OAuth state file (500 ms interval) until a token for `--server-url` appears, then proceed as if `--use-stored-auth` were set. Times out at `<sec>` with exit `3` (`auth_wait_timeout`). Use after handing off to a human to complete OAuth in a browser. |
|
||||
| `--list-stored-auth` | Print `{ oauthStatePath, storedServerUrls }` (the server keys that currently have a token) and exit. No server connection is made. |
|
||||
| `--print-handoff` | Print a JSON handoff block (`deepLink`, `portForwardCmd`, `oauthStatePath`, `apiToken`) for `--server-url` and exit — everything a script/remote VM needs to drive the browser-side OAuth dance. Requires `--server-url`. |
|
||||
|
||||
**State-file resolution** follows `MCP_INSPECTOR_OAUTH_STATE_PATH` → `<MCP_STORAGE_DIR>/oauth.json` → `~/.mcp-inspector/storage/oauth.json` — the same precedence the rest of the Inspector uses, so the CLI and web backend agree on the file. Server keys are canonicalised with `new URL().href` (the scheme the web store writes), so a trailing-slash or case mismatch between the URL a human opened and the one the agent passed still resolves.
|
||||
|
||||
**Blind injection.** The token is injected as-is; the CLI does not validate or refresh it (a stored `refresh_token` is not yet used — a natural follow-up). A stale/expired access token surfaces as an HTTP `401` on the first request → exit `3` (`auth_required`). Re-complete the flow in the web inspector (or use `--wait-for-auth`) and retry.
|
||||
**Token refresh (#1665).** When the stored server state carries a `refresh_token` (plus the `clientInformation` the web inspector persists after a completed flow), `--use-stored-auth` runs the SDK's OAuth `refresh_token` grant to mint a fresh access token before connecting, then writes the rotated tokens back to the state file so web and CLI stay consistent. The auth-server metadata is reused from the stored state, or discovered from `--server-url` when absent. This covers both an absent and an expired stored access token — the persisted blob carries no expiry, so the refresh token is treated as the durable credential. A failed refresh (revoked/expired refresh token) exits `3` (`auth_required`, envelope `refresh_failed`); re-complete the flow in the web inspector and retry. Without a stored `refresh_token` the access token is injected as-is, and a stale one surfaces as an HTTP `401` → exit `3`.
|
||||
|
||||
**Short-circuit modes.** `--list-stored-auth` and `--print-handoff` each print their output and exit without connecting to a server; they ignore the method/target flags. They are mutually exclusive — if both are passed, `--list-stored-auth` takes precedence.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { runCli } from "./helpers/cli-runner.js";
|
||||
import { expectCliFailure, expectCliSuccess } from "./helpers/assertions.js";
|
||||
import { normalizeServerUrl } from "../src/cli.js";
|
||||
import { normalizeServerUrl, refreshStoredAuthToken } from "../src/cli.js";
|
||||
import {
|
||||
createTestServerHttp,
|
||||
createEchoTool,
|
||||
|
|
@ -36,6 +37,137 @@ describe("normalizeServerUrl", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("refreshStoredAuthToken", () => {
|
||||
const SERVER = "https://api.example/mcp";
|
||||
const freshTokens = {
|
||||
access_token: "refreshed-access-token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "rotated-refresh-token",
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
it("runs the refresh grant, injects the fresh token, and persists the rotation", async () => {
|
||||
const path = writeOAuthFixture({
|
||||
[SERVER]: {
|
||||
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
|
||||
clientInformation: { client_id: "cid", client_secret: "sec" },
|
||||
serverMetadata: {
|
||||
issuer: "https://auth.example",
|
||||
token_endpoint: "https://auth.example/token",
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const refresh = vi.fn().mockResolvedValue(freshTokens);
|
||||
const discover = vi.fn();
|
||||
const token = await refreshStoredAuthToken(SERVER, path, {
|
||||
refresh,
|
||||
discover,
|
||||
});
|
||||
expect(token).toBe("refreshed-access-token");
|
||||
// Stored metadata present → no discovery needed.
|
||||
expect(discover).not.toHaveBeenCalled();
|
||||
// Refresh called with the stored refresh token + client information.
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
const [, opts] = refresh.mock.calls[0]!;
|
||||
expect(opts.refreshToken).toBe("old-refresh");
|
||||
expect(opts.clientInformation).toEqual({
|
||||
client_id: "cid",
|
||||
client_secret: "sec",
|
||||
});
|
||||
// Rotation persisted back under the same key.
|
||||
const persisted = JSON.parse(readFileSync(path, "utf8")) as {
|
||||
servers: Record<string, { tokens?: { refresh_token?: string } }>;
|
||||
};
|
||||
expect(persisted.servers[SERVER]?.tokens?.refresh_token).toBe(
|
||||
"rotated-refresh-token",
|
||||
);
|
||||
} finally {
|
||||
rmSync(path, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("discovers the auth-server metadata when it was not persisted", async () => {
|
||||
const path = writeOAuthFixture({
|
||||
[SERVER]: {
|
||||
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
|
||||
clientInformation: { client_id: "cid" },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const refresh = vi.fn().mockResolvedValue(freshTokens);
|
||||
const discover = vi.fn().mockResolvedValue({
|
||||
issuer: "https://api.example",
|
||||
token_endpoint: "https://api.example/token",
|
||||
});
|
||||
const token = await refreshStoredAuthToken(SERVER, path, {
|
||||
refresh,
|
||||
discover,
|
||||
});
|
||||
expect(token).toBe("refreshed-access-token");
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
rmSync(path, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws AUTH_REQUIRED when no refresh token is stored", async () => {
|
||||
const path = writeOAuthFixture({
|
||||
[SERVER]: {
|
||||
tokens: { access_token: "only-access", token_type: "Bearer" },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
refreshStoredAuthToken(SERVER, path, { refresh: vi.fn() }),
|
||||
).rejects.toMatchObject({ exitCode: 3 });
|
||||
} finally {
|
||||
rmSync(path, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws AUTH_REQUIRED when a refresh token is present but client information is missing", async () => {
|
||||
const path = writeOAuthFixture({
|
||||
[SERVER]: {
|
||||
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
refreshStoredAuthToken(SERVER, path, { refresh: vi.fn() }),
|
||||
).rejects.toMatchObject({ exitCode: 3 });
|
||||
} finally {
|
||||
rmSync(path, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces a refresh-grant failure as AUTH_REQUIRED (refresh_failed)", async () => {
|
||||
const path = writeOAuthFixture({
|
||||
[SERVER]: {
|
||||
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
|
||||
clientInformation: { client_id: "cid" },
|
||||
serverMetadata: {
|
||||
issuer: "https://auth.example",
|
||||
token_endpoint: "https://auth.example/token",
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const refresh = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("invalid_grant: token revoked"));
|
||||
await expect(
|
||||
refreshStoredAuthToken(SERVER, path, { refresh }),
|
||||
).rejects.toMatchObject({
|
||||
exitCode: 3,
|
||||
envelope: { code: "refresh_failed" },
|
||||
});
|
||||
} finally {
|
||||
rmSync(path, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("--use-stored-auth", () => {
|
||||
let server: ReturnType<typeof createTestServerHttp>;
|
||||
let serverUrl: string;
|
||||
|
|
@ -197,6 +329,71 @@ describe("--use-stored-auth", () => {
|
|||
const last = server.getRecordedRequests().at(-1)!;
|
||||
expect(last.headers?.authorization).toBe(`Bearer ${TOKEN}`);
|
||||
});
|
||||
|
||||
it("refreshes a stored refresh_token end-to-end and injects the fresh access token (#1665)", async () => {
|
||||
// Minimal OAuth token endpoint: honors the refresh_token grant with a
|
||||
// rotated token pair, so the CLI's real SDK refresh path has something to
|
||||
// talk to (the MCP test server accepts any bearer, so a successful connect
|
||||
// proves the refreshed token was injected).
|
||||
let tokenRequests = 0;
|
||||
const tokenServer: Server = createServer((req, res) => {
|
||||
tokenRequests += 1;
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: "refreshed-access-token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "rotated-refresh-token",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => tokenServer.listen(0, resolve));
|
||||
const addr = tokenServer.address();
|
||||
const tokenBase =
|
||||
typeof addr === "object" && addr ? `http://127.0.0.1:${addr.port}` : "";
|
||||
const fixture = writeOAuthFixture({
|
||||
[serverUrl]: {
|
||||
tokens: { refresh_token: "old-refresh", token_type: "Bearer" },
|
||||
clientInformation: { client_id: "cid", client_secret: "sec" },
|
||||
serverMetadata: {
|
||||
issuer: tokenBase,
|
||||
token_endpoint: `${tokenBase}/token`,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await runCli(
|
||||
[
|
||||
"--transport",
|
||||
"http",
|
||||
"--server-url",
|
||||
serverUrl,
|
||||
"--use-stored-auth",
|
||||
"--method",
|
||||
"tools/list",
|
||||
],
|
||||
{ env: { MCP_INSPECTOR_OAUTH_STATE_PATH: fixture } },
|
||||
);
|
||||
expectCliSuccess(result);
|
||||
expect(tokenRequests).toBeGreaterThan(0);
|
||||
const last = server.getRecordedRequests().at(-1)!;
|
||||
expect(last.headers?.authorization).toBe("Bearer refreshed-access-token");
|
||||
// Rotation persisted so a subsequent run reuses the new refresh token.
|
||||
const persisted = JSON.parse(readFileSync(fixture, "utf8")) as {
|
||||
servers: Record<string, { tokens?: { refresh_token?: string } }>;
|
||||
};
|
||||
expect(persisted.servers[serverUrl]?.tokens?.refresh_token).toBe(
|
||||
"rotated-refresh-token",
|
||||
);
|
||||
} finally {
|
||||
rmSync(fixture, { force: true });
|
||||
await new Promise<void>((resolve) => tokenServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("--list-stored-auth", () => {
|
||||
|
|
|
|||
|
|
@ -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