diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index b2d5899f..200c7f34 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -534,24 +534,34 @@ try { } } } else if (topLevelAuthHelp) { - process.stdout.write("Usage: step login\nSign in with the Step account and store a credential.\n"); + process.stdout.write( + "Usage: step login [--no-browser]\nSign in with the Step account and store a credential.\n\n --no-browser Print the sign-in URL instead of opening a browser.\n", + ); } else if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) { process.stderr.write( "step login needs an interactive terminal. Set STEP_API_KEY instead, or run it from a terminal.\n", ); process.exitCode = 1; } else { - const outcome = await runStepLogin({ - authPath: getStepAuthPath(), - themeName: getStepDefaultTheme(), - }); - if (outcome.kind === "completed") { - syncStepLoginProfileEndpoint(getStepAuthPath()); - process.stdout.write(`Signed in${outcome.profile ? ` with ${outcome.profile.title}` : ""}.\n`); - if (outcome.credentialsPath) process.stdout.write(`Credential written: ${outcome.credentialsPath}\n`); - } else { - process.stderr.write("Sign-in cancelled. No credential was written.\n"); + const loginArgs = process.argv.slice(3); + const unknownLoginArg = loginArgs.find((arg) => arg !== "--no-browser"); + if (unknownLoginArg) { + process.stderr.write(`Unknown option "${unknownLoginArg}" for "login".\n`); process.exitCode = 1; + } else { + const outcome = await runStepLogin({ + authPath: getStepAuthPath(), + themeName: getStepDefaultTheme(), + noBrowser: loginArgs.includes("--no-browser"), + }); + if (outcome.kind === "completed") { + syncStepLoginProfileEndpoint(getStepAuthPath()); + process.stdout.write(`Signed in${outcome.profile ? ` with ${outcome.profile.title}` : ""}.\n`); + if (outcome.credentialsPath) process.stdout.write(`Credential written: ${outcome.credentialsPath}\n`); + } else { + process.stderr.write("Sign-in cancelled. No credential was written.\n"); + process.exitCode = 1; + } } } } else if (isTopLevelFeedback) { diff --git a/packages/coding-agent/src/features/step-provider/index.ts b/packages/coding-agent/src/features/step-provider/index.ts index a02ce7d1..d6f3b07c 100644 --- a/packages/coding-agent/src/features/step-provider/index.ts +++ b/packages/coding-agent/src/features/step-provider/index.ts @@ -49,7 +49,7 @@ function createStepProviderConfigFromResolved(resolved: ResolvedStepProviderOpti name: "Step Plan", isSubscription: true, login: (callbacks) => loginStepOAuth(callbacks, resolved), - refreshToken: (credentials, signal) => refreshStepOAuth(credentials, resolved, signal), + refreshToken: refreshStepOAuth, getApiKey: getStepOAuthApiKey, }, }; diff --git a/packages/coding-agent/src/step/login-flow.ts b/packages/coding-agent/src/step/login-flow.ts index 92fc1073..73f8d2c1 100644 --- a/packages/coding-agent/src/step/login-flow.ts +++ b/packages/coding-agent/src/step/login-flow.ts @@ -1,6 +1,11 @@ import { ProcessTerminal, type TUI, TuiMainScreen } from "@step-harness/pi-tui"; import { AuthStorage, readStoredCredential } from "../core/auth-storage.ts"; -import { loginStepOAuth, STEP_PROVIDER_ID, STEP_STATIC_REFRESH_TOKEN } from "../features/step-provider/index.ts"; +import { + defaultStepCliClientInfo, + loginStepOAuth, + STEP_PROVIDER_ID, + STEP_STATIC_REFRESH_TOKEN, +} from "../features/step-provider/index.ts"; import { detectTerminalBackgroundFromEnv, initTheme, resolveThemeSetting, theme } from "../theme/theme.ts"; import { openBrowser } from "../utils/open-browser.ts"; import { resolveStepAgentDir } from "./environment.ts"; @@ -15,6 +20,7 @@ import { type StepLoginStep, } from "./onboarding.ts"; import { StepOnboardingView } from "./onboarding-view.ts"; +import { resolveStepCodeVersion } from "./version.ts"; export interface StepLoginHost { addChild(child: unknown): void; @@ -38,6 +44,11 @@ export interface RunStepLoginOptions { readonly now?: () => Date; readonly env?: Record; readonly themeName?: string; + /** + * Never try to launch a browser; only print the URL. Useful over SSH and in + * containers, where a launcher may "succeed" without a window ever appearing. + */ + readonly noBrowser?: boolean; } export async function writeStepLoginCredential(input: { @@ -224,10 +235,13 @@ export async function runStepLogin(options: RunStepLoginOptions = {}): Promise { + // Show the URL before launching anything: if the launcher hangs or + // the host has no browser, the user still has something to copy. dispatch({ type: "browserOpened", authUrl: url }); - openBrowser(url); + if (!options.noBrowser) openBrowser(url); }, onDeviceCode: () => {}, + // Cloud login never prompts for a callback URL or opens a local port. onPrompt: async () => "", onSelect: async () => undefined, }, @@ -235,6 +249,8 @@ export async function runStepLogin(options: RunStepLoginOptions = {}): Promise wrapTextWithAnsi(row, safeWidth)); + } return rows.map((row) => truncateToWidth(row, safeWidth, "", false)); } @@ -163,8 +169,10 @@ export class StepOnboardingView extends Container implements Component, Focusabl rows.push( "Continue sign-in in your browser:", "", + // On its own unindented row, wrapping rather than truncating. theme.fg("accent", this.step.authUrl || "Opening sign-in page..."), ); + rows.push("", muted("If the browser does not open here, copy this URL into a browser on any device.")); rows.push("", muted(" Esc cancel")); return rows; case "saving": diff --git a/packages/coding-agent/src/utils/open-browser.ts b/packages/coding-agent/src/utils/open-browser.ts index 435e23f9..58afd922 100644 --- a/packages/coding-agent/src/utils/open-browser.ts +++ b/packages/coding-agent/src/utils/open-browser.ts @@ -15,10 +15,12 @@ export function openBrowser(target: string): void { ? ["rundll32", ["url.dll,FileProtocolHandler", target]] : ["xdg-open", [target]]; - // spawn reports launcher failures (for example, missing xdg-open) via an - // error event. Browser launch is best-effort: callers still present the target - // to the user, so keep the launcher failure from becoming a process crash. - spawn(cmd, args, { stdio: "ignore", detached: true }) - .on("error", () => {}) - .unref(); + // Browser launch is best-effort; callers still display the URL. + try { + spawn(cmd, args, { stdio: "ignore", detached: true }) + .on("error", () => {}) + .unref(); + } catch { + // Missing launchers or desktop support must not interrupt cloud login. + } } diff --git a/packages/coding-agent/test/open-browser.test.ts b/packages/coding-agent/test/open-browser.test.ts new file mode 100644 index 00000000..680f9c63 --- /dev/null +++ b/packages/coding-agent/test/open-browser.test.ts @@ -0,0 +1,27 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { openBrowser } from "../src/utils/open-browser.ts"; + +const { spawn } = vi.hoisted(() => ({ spawn: vi.fn() })); +vi.mock("node:child_process", () => ({ spawn })); +afterEach(() => spawn.mockReset()); + +describe("best-effort browser launch", () => { + it("passes the URL without a shell and does not wait for the browser", () => { + const child = Object.assign(new EventEmitter(), { unref: vi.fn() }); + spawn.mockReturnValue(child); + const url = "https://example.test/cli-login-remote?flow_id=123&x=y"; + expect(openBrowser(url)).toBeUndefined(); + expect(spawn.mock.calls[0]?.[1]).toContain(url); + expect(spawn.mock.calls[0]?.[2]).not.toHaveProperty("shell"); + expect(child.unref).toHaveBeenCalledOnce(); + expect(() => child.emit("error", new Error("ENOENT"))).not.toThrow(); + expect(() => child.emit("exit", 3, null)).not.toThrow(); + }); + it("does not interrupt login on a synchronous launcher failure", () => { + spawn.mockImplementation(() => { + throw new Error("no desktop"); + }); + expect(() => openBrowser("https://example.test")).not.toThrow(); + }); +}); diff --git a/packages/coding-agent/test/step-cli-login.test.ts b/packages/coding-agent/test/step-cli-login.test.ts new file mode 100644 index 00000000..58b6421b --- /dev/null +++ b/packages/coding-agent/test/step-cli-login.test.ts @@ -0,0 +1,573 @@ +import { Server } from "node:net"; +import type { OAuthLoginCallbacks } from "@step-harness/providers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + initStepCliLogin, + isRetryableStepCliLoginError, + loginStepOAuth, + MIN_STEP_POLL_INTERVAL_MS, + pollStepCliLogin, + resolveStepProviderOptions, + STEP_CLI_LOGIN_PATH_PREFIX, + StepCliLoginRequestError, + waitForStepCliLogin, +} from "../src/features/step-provider/index.ts"; + +const AUTH_BASE_URL = "https://auth.example.test"; +const CLIENT = { name: "stepcode", version: "1.2.3", platform: "darwin-arm64" } as const; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function initBody(overrides: Record = {}): Record { + return { + status: 0, + desc: "", + flow_id: "flow-1", + poll_interval_sec: 2, + expires_at: Math.floor(Date.now() / 1000) + 600, + ...overrides, + }; +} + +function callbacks(overrides: Partial = {}): OAuthLoginCallbacks { + return { + onAuth: vi.fn(), + onDeviceCode: vi.fn(), + onPrompt: vi.fn(async () => ""), + onSelect: vi.fn(async () => undefined), + ...overrides, + }; +} + +describe("Step CLI login init", () => { + it("sends the poll token in the header only and never in the URL", async () => { + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + expect(url).toBe(`${AUTH_BASE_URL}${STEP_CLI_LOGIN_PATH_PREFIX}/init`); + expect(url).not.toContain("poll-token-value"); + expect(init?.method).toBe("POST"); + expect(url).toBe(`${AUTH_BASE_URL}/api/stdhttp/v1/cli-login/init`); + expect(init?.redirect).toBe("error"); + expect(init?.credentials).toBe("omit"); + const headers = init?.headers as Record; + expect(headers.authorization).toBe("Bearer poll-token-value"); + expect(JSON.parse(String(init?.body))).toEqual({ + profile: "step_plan", + origin: AUTH_BASE_URL, + client: CLIENT, + }); + return json(initBody()); + }); + + const init = await initStepCliLogin({ + authBaseUrl: AUTH_BASE_URL, + pollToken: "poll-token-value", + profile: "step_plan", + client: CLIENT, + fetch: fetchMock, + }); + + expect(init.flowId).toBe("flow-1"); + expect(init.authorizeUrl).toBe(`${AUTH_BASE_URL}/cli-login-remote?flow_id=flow-1`); + expect(init.pollIntervalSec).toBe(2); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("constructs the authorization URL from the selected developer-center origin", async () => { + const init = await initStepCliLogin({ + authBaseUrl: AUTH_BASE_URL, + pollToken: "token", + profile: "step_plan", + client: CLIENT, + fetch: async () => json(initBody({ authorize_url: "https://evil.example.test/phishing" })), + }); + expect(init.authorizeUrl).toBe(`${AUTH_BASE_URL}/cli-login-remote?flow_id=flow-1`); + }); + + it("uses the overseas developer center and profile for overseas login", async () => { + const authBaseUrl = "https://platform.stepfun.ai"; + const init = await initStepCliLogin({ + authBaseUrl, + pollToken: "oversea-poll-token", + profile: "step_plan_oversea", + client: CLIENT, + fetch: async (input, options) => { + expect(String(input)).toBe(`${authBaseUrl}${STEP_CLI_LOGIN_PATH_PREFIX}/init`); + expect(JSON.parse(String(options?.body))).toEqual({ + profile: "step_plan_oversea", + origin: authBaseUrl, + client: CLIENT, + }); + return json(initBody()); + }, + }); + + expect(init.authorizeUrl).toBe(`${authBaseUrl}/cli-login-remote?flow_id=flow-1`); + }); + + it("rejects a missing flow_id, a sub-second interval and a missing expiry", async () => { + const bad = [{ flow_id: "" }, { flow_id: "has spaces" }, { poll_interval_sec: 0.5 }, { expires_at: 0 }]; + for (const overrides of bad) { + await expect( + initStepCliLogin({ + authBaseUrl: AUTH_BASE_URL, + pollToken: "token", + profile: "step_plan", + client: CLIENT, + fetch: async () => json(initBody(overrides)), + }), + ).rejects.toMatchObject({ kind: "protocol" }); + } + }); + + it("clamps an absurd poll interval instead of parking the terminal", async () => { + const init = await initStepCliLogin({ + authBaseUrl: AUTH_BASE_URL, + pollToken: "token", + profile: "step_plan", + client: CLIENT, + fetch: async () => json(initBody({ poll_interval_sec: 86_400 })), + }); + expect(init.pollIntervalSec).toBe(60); + }); + + it("reports the HTTP status before looking at the envelope", async () => { + const error = await initStepCliLogin({ + authBaseUrl: AUTH_BASE_URL, + pollToken: "token", + profile: "step_plan", + client: CLIENT, + fetch: async () => json({ status: 40301, desc: "no plan" }, 403), + }).catch((caught: unknown) => caught); + expect(error).toMatchObject({ kind: "http", httpStatus: 403 }); + expect((error as Error).message).toContain("no plan"); + }); + + it("refuses a response larger than the cap", async () => { + const huge = JSON.stringify({ status: 0, desc: "x".repeat(70 * 1024) }); + await expect( + initStepCliLogin({ + authBaseUrl: AUTH_BASE_URL, + pollToken: "token", + profile: "step_plan", + client: CLIENT, + fetch: async () => new Response(huge, { status: 200, headers: { "content-type": "application/json" } }), + }), + ).rejects.toMatchObject({ kind: "protocol" }); + }); + + it("never sends a poll token over plain HTTP", async () => { + const fetchMock = vi.fn(); + await expect( + initStepCliLogin({ + authBaseUrl: "http://auth.example.test", + pollToken: "token", + profile: "step_plan", + client: CLIENT, + fetch: fetchMock, + }), + ).rejects.toThrow(/https/u); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("keeps HTTP retry semantics even when the error body is oversized", async () => { + await expect( + initStepCliLogin({ + authBaseUrl: AUTH_BASE_URL, + pollToken: "token", + profile: "step_plan", + client: CLIENT, + fetch: async () => new Response("x".repeat(70 * 1024), { status: 503 }), + }), + ).rejects.toMatchObject({ kind: "http", httpStatus: 503 }); + }); + + it("bounds a request that never resolves and aborts its transport", async () => { + vi.useFakeTimers(); + try { + let signal: AbortSignal | undefined; + const result = initStepCliLogin({ + authBaseUrl: AUTH_BASE_URL, + pollToken: "token", + profile: "step_plan", + client: CLIENT, + fetch: async (_, options) => { + signal = options?.signal ?? undefined; + return new Promise(() => {}); + }, + }).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(15_000); + expect(await result).toMatchObject({ kind: "transport" }); + expect(signal?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("bounds a stalled response body too", async () => { + vi.useFakeTimers(); + try { + const result = initStepCliLogin({ + authBaseUrl: AUTH_BASE_URL, + pollToken: "token", + profile: "step_plan", + client: CLIENT, + fetch: async () => new Response(new ReadableStream()), + }).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(15_000); + expect(await result).toMatchObject({ kind: "transport" }); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("Step CLI login poll", () => { + it("maps pending, ready and failed states", async () => { + const read = (body: unknown) => + pollStepCliLogin({ + authBaseUrl: AUTH_BASE_URL, + pollToken: "token", + flowId: "flow-1", + fetch: async (input, init) => { + expect(String(input)).toBe(`${AUTH_BASE_URL}${STEP_CLI_LOGIN_PATH_PREFIX}/poll/flow-1`); + expect((init?.headers as Record).authorization).toBe("Bearer token"); + return json(body); + }, + }); + + await expect(read({ status: 0, state: "pending" })).resolves.toEqual({ state: "pending" }); + await expect( + read({ status: 0, state: "ready", uid: "u-1", profile: "step_plan", credential: { api_key: "sk-step" } }), + ).resolves.toEqual({ state: "ready", apiKey: "sk-step", uid: "u-1" }); + await expect(read({ status: 0, state: "failed", reason: "denied" })).resolves.toEqual({ + state: "failed", + reason: "denied", + }); + await expect(read({ status: 0, state: "failed", reason: "something-new" })).resolves.toEqual({ + state: "failed", + reason: "unknown", + }); + }); + + it("treats a ready response without an api_key as a protocol error", async () => { + await expect( + pollStepCliLogin({ + authBaseUrl: AUTH_BASE_URL, + pollToken: "token", + flowId: "flow-1", + fetch: async () => json({ status: 0, state: "ready", credential: {} }), + }), + ).rejects.toMatchObject({ kind: "protocol" }); + }); + + it("classifies retryable and fatal failures", () => { + const make = (kind: "http" | "protocol" | "transport", httpStatus?: number) => + new StepCliLoginRequestError(kind, "boom", httpStatus === undefined ? {} : { httpStatus }); + expect(isRetryableStepCliLoginError(make("http", 429))).toBe(true); + expect(isRetryableStepCliLoginError(make("http", 503))).toBe(true); + expect(isRetryableStepCliLoginError(make("http", 408))).toBe(true); + expect(isRetryableStepCliLoginError(make("transport"))).toBe(true); + expect(isRetryableStepCliLoginError(make("http", 404))).toBe(false); + expect(isRetryableStepCliLoginError(make("protocol"))).toBe(false); + expect(isRetryableStepCliLoginError(new Error("unrelated"))).toBe(false); + }); + + it("classifies interrupted response bodies as retryable transport failures", async () => { + const result = pollStepCliLogin({ + authBaseUrl: AUTH_BASE_URL, + pollToken: "token", + flowId: "flow-1", + fetch: async () => + new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error("socket closed")); + }, + }), + ), + }).catch((error: unknown) => error); + expect(await result).toMatchObject({ kind: "transport" }); + expect(isRetryableStepCliLoginError(await result)).toBe(true); + }); +}); + +describe("Step CLI login polling loop", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + const defaults = () => ({ + init: { flowId: "flow-1", authorizeUrl: AUTH_BASE_URL, pollIntervalSec: 2, expiresAt: Date.now() / 1000 + 600 }, + authBaseUrl: AUTH_BASE_URL, + pollToken: "token", + }); + + it("keeps polling through transient failures until a credential arrives", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(json({ desc: "busy" }, 429)) + .mockResolvedValueOnce(json({ state: "pending" })) + .mockResolvedValueOnce(json({ state: "ready", credential: { api_key: "key" } })); + const result = waitForStepCliLogin({ ...defaults(), fetch }); + await vi.advanceTimersByTimeAsync(4_000); + expect(await result).toEqual({ state: "ready", apiKey: "key" }); + expect(fetch).toHaveBeenCalledTimes(3); + expect(vi.getTimerCount()).toBe(0); + }); + + it("never polls faster than the floor", async () => { + const input = defaults(); + const fetch = vi + .fn() + .mockResolvedValueOnce(json({ state: "pending" })) + .mockResolvedValueOnce(json({ state: "ready", credential: { api_key: "key" } })); + const result = waitForStepCliLogin({ ...input, init: { ...input.init, pollIntervalSec: 0 }, fetch }); + await vi.advanceTimersByTimeAsync(MIN_STEP_POLL_INTERVAL_MS - 1); + expect(fetch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(await result).toMatchObject({ apiKey: "key" }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("stops at the server expiry", async () => { + const input = defaults(); + const fetch = vi.fn(async () => json({ state: "pending" })); + const result = waitForStepCliLogin({ + ...input, + init: { ...input.init, expiresAt: Date.now() / 1000 + 5 }, + fetch, + }).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(5_000); + expect(await result).toBeInstanceOf(Error); + expect(await result).toMatchObject({ message: expect.stringContaining("timed out") }); + expect(fetch).toHaveBeenCalledTimes(3); + expect(vi.getTimerCount()).toBe(0); + }); + + it("surfaces a denied flow immediately", async () => { + await expect( + waitForStepCliLogin({ ...defaults(), fetch: async () => json({ state: "failed", reason: "denied" }) }), + ).rejects.toThrow("Sign-in was denied in the browser."); + }); + + it("does not retry a fatal poll failure", async () => { + const fetch = vi.fn(async () => json({ desc: "gone" }, 404)); + await expect(waitForStepCliLogin({ ...defaults(), fetch })).rejects.toMatchObject({ httpStatus: 404 }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("cancels during the wait between polls", async () => { + const controller = new AbortController(); + const fetch = vi.fn(async () => json({ state: "pending" })); + const result = waitForStepCliLogin({ ...defaults(), fetch, signal: controller.signal }).catch( + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(0); + controller.abort(new Error("cancelled by user")); + expect(await result).toMatchObject({ message: "cancelled by user" }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it("expires even while a poll is hung", async () => { + const input = defaults(); + let signal: AbortSignal | undefined; + const result = waitForStepCliLogin({ + ...input, + init: { ...input.init, expiresAt: Date.now() / 1000 + 2 }, + fetch: async (_, options) => { + signal = options?.signal ?? undefined; + return new Promise(() => {}); + }, + }).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(2_000); + expect(await result).toMatchObject({ message: expect.stringContaining("timed out") }); + expect(signal?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it("rejects a ready result that races with cancellation", async () => { + const controller = new AbortController(); + await expect( + waitForStepCliLogin({ + ...defaults(), + signal: controller.signal, + fetch: async () => { + controller.abort(new Error("cancelled")); + return json({ state: "ready", credential: { api_key: "key" } }); + }, + }), + ).rejects.toThrow("cancelled"); + }); +}); + +describe("Step cloud-only login", () => { + afterEach(() => vi.restoreAllMocks()); + + it("generates a fresh token per login and reuses it only for that flow", async () => { + const existingApiKey = "a".repeat(64); + const tokens: string[] = []; + const fetchMock = vi.fn(async (url: string | URL | Request, options?: RequestInit) => { + const headers = new Headers(options?.headers); + const bearer = headers.get("authorization") ?? ""; + tokens.push(bearer); + expect(headers.has("cookie")).toBe(false); + expect(headers.has("oasis-token")).toBe(false); + expect(options?.credentials).toBe("omit"); + expect(String(url)).not.toContain(bearer.slice(7)); + expect(String(options?.body ?? "")).not.toContain(bearer.slice(7)); + return String(url).endsWith("/init") + ? json(initBody()) + : json({ state: "ready", credential: { api_key: "key" } }); + }); + const options = { authBaseUrl: AUTH_BASE_URL, env: { STEP_API_KEY: existingApiKey }, fetch: fetchMock }; + await loginStepOAuth(callbacks(), options); + await loginStepOAuth(callbacks(), options); + expect(tokens).toHaveLength(4); + expect(tokens[0]).toMatch(/^Bearer [0-9a-f]{64}$/u); + expect(tokens[0]).toBe(tokens[1]); + expect(tokens[2]).toBe(tokens[3]); + expect(tokens[0]).not.toBe(tokens[2]); + expect(tokens).not.toContain(`Bearer ${existingApiKey}`); + }); + + it("opens the fixed approval path on the selected developer-center origin", async () => { + const authorizeUrl = `${AUTH_BASE_URL}/cli-login-remote?flow_id=flow-1`; + const onAuth = vi.fn(); + await loginStepOAuth(callbacks({ onAuth }), { + authBaseUrl: AUTH_BASE_URL, + fetch: async (url) => + String(url).endsWith("/init") + ? json(initBody()) + : json({ status: 0, state: "ready", credential: { api_key: "polled-key" } }), + }); + expect(onAuth).toHaveBeenCalledWith(expect.objectContaining({ url: authorizeUrl })); + }); + + function forbidLocalServer() { + return vi.spyOn(Server.prototype, "listen").mockImplementation(() => { + throw new Error("Step login must never listen on a local port"); + }); + } + + it.each([undefined, "loopback", "auto", "poll"])( + "uses the cloud regardless of removed mode setting %s", + async (legacyMode) => { + const listen = forbidLocalServer(); + const fetchMock = vi.fn(async (input: string | URL | Request) => + String(input).endsWith("/init") + ? json(initBody()) + : json({ status: 0, state: "ready", uid: "u-9", credential: { api_key: "polled-key" } }), + ); + const onAuth = vi.fn(); + const options = resolveStepProviderOptions({ + authBaseUrl: AUTH_BASE_URL, + loginProfile: "step_plan", + client: CLIENT, + env: { STEPCODE_CLI_LOGIN_MODE: legacyMode, STEP_OAUTH_CALLBACK_PORT: "invalid-old-setting" }, + fetch: fetchMock, + }); + const credential = await loginStepOAuth(callbacks({ onAuth }), options); + expect(credential.access).toBe("polled-key"); + expect(credential.uid).toBe("u-9"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(listen).not.toHaveBeenCalled(); + for (const removed of [ + "cliLoginMode", + "callbackHost", + "callbackPort", + "createState", + "createCallbackServer", + "allowManualCallback", + "createPollToken", + ]) { + expect(options).not.toHaveProperty(removed); + } + expect(onAuth).toHaveBeenCalledWith( + expect.objectContaining({ url: `${AUTH_BASE_URL}/cli-login-remote?flow_id=flow-1` }), + ); + }, + ); + + it.each([404, 405, 501])("reports unsupported cloud HTTP %s without opening a local server", async (status) => { + const listen = forbidLocalServer(); + const onAuth = vi.fn(); + await expect( + loginStepOAuth(callbacks({ onAuth }), { + authBaseUrl: AUTH_BASE_URL, + fetch: async () => json({ desc: "not supported" }, status), + }), + ).rejects.toMatchObject({ httpStatus: status }); + expect(onAuth).not.toHaveBeenCalled(); + expect(listen).not.toHaveBeenCalled(); + }); + + it.each(["protocol", "transport"])("surfaces an init %s failure without fallback", async (kind) => { + const listen = forbidLocalServer(); + const onAuth = vi.fn(); + await expect( + loginStepOAuth(callbacks({ onAuth }), { + authBaseUrl: AUTH_BASE_URL, + fetch: async () => { + if (kind === "transport") throw new Error("network unavailable"); + return new Response("Unsupported endpoint"); + }, + }), + ).rejects.toMatchObject({ kind }); + expect(onAuth).not.toHaveBeenCalled(); + expect(listen).not.toHaveBeenCalled(); + }); + + it("surfaces a denied cloud flow", async () => { + const listen = forbidLocalServer(); + await expect( + loginStepOAuth(callbacks(), { + authBaseUrl: AUTH_BASE_URL, + fetch: async (url) => + String(url).endsWith("/init") + ? json(initBody()) + : json({ status: 0, state: "failed", reason: "denied" }), + }), + ).rejects.toThrow("Sign-in was denied in the browser."); + expect(listen).not.toHaveBeenCalled(); + }); + + it("includes init in the login deadline", async () => { + vi.useFakeTimers(); + try { + const listen = forbidLocalServer(); + const result = loginStepOAuth(callbacks(), { + authBaseUrl: AUTH_BASE_URL, + timeoutMs: 1_000, + fetch: async () => new Promise(() => {}), + }).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(1_000); + expect(await result).toMatchObject({ message: expect.stringContaining("timed out") }); + expect(listen).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("does not use echoed profile metadata as an authentication check", async () => { + await expect( + loginStepOAuth(callbacks(), { + authBaseUrl: AUTH_BASE_URL, + loginProfile: "step_plan", + fetch: async (url) => + String(url).endsWith("/init") + ? json(initBody()) + : json({ + status: 0, + state: "ready", + profile: "step_plan_oversea", + credential: { api_key: "wrong-region" }, + }), + }), + ).resolves.toMatchObject({ access: "wrong-region" }); + }); +}); diff --git a/packages/coding-agent/test/step-login-browser.test.ts b/packages/coding-agent/test/step-login-browser.test.ts new file mode 100644 index 00000000..89a6ed52 --- /dev/null +++ b/packages/coding-agent/test/step-login-browser.test.ts @@ -0,0 +1,91 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readStepLoginCredential, runStepLogin } from "../src/step/login-flow.ts"; +import type { StepOnboardingView } from "../src/step/onboarding-view.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; +import * as browser from "../src/utils/open-browser.ts"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("Step login browser launch and URL fallback", () => { + it.each([ + { name: "opens the local browser by default", noBrowser: false }, + { name: "only displays the URL with --no-browser", noBrowser: true }, + ])("$name", async ({ noBrowser }) => { + const root = await mkdtemp(join(tmpdir(), "step-login-browser-")); + const authPath = join(root, "auth.json"); + const authUrl = `https://platform.stepfun.com/cli-login-remote?flow_id=${"a".repeat(32)}`; + let view: StepOnboardingView | undefined; + let resolvePoll!: (response: Response) => void; + const pollResponse = new Promise((resolve) => { + resolvePoll = resolve; + }); + let pollStarted!: () => void; + const polling = new Promise((resolve) => { + pollStarted = resolve; + }); + const openBrowser = vi.spyOn(browser, "openBrowser").mockImplementation((url) => { + expect(url).toBe(authUrl); + // Render the complete URL before even attempting to launch a browser. + expect(view?.render(40).map(stripAnsi).join("")).toContain(authUrl); + }); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url.endsWith("/init")) + return new Response( + JSON.stringify({ + status: 0, + flow_id: "a".repeat(32), + poll_interval_sec: 2, + expires_at: Date.now() / 1000 + 600, + }), + ); + pollStarted(); + return pollResponse; + }), + ); + const login = runStepLogin({ + authPath, + env: {}, + ...(noBrowser ? { noBrowser: true } : {}), + createHost: () => ({ + addChild: (child) => { + view = child as StepOnboardingView; + }, + setFocus: () => {}, + requestRender: () => {}, + stop: () => {}, + start: () => view?.handleInput("1"), + }), + }); + try { + await polling; + await vi.waitFor(() => + expect(view?.getStep()).toMatchObject({ + kind: "continueInBrowser", + authUrl, + }), + ); + expect(openBrowser).toHaveBeenCalledTimes(noBrowser ? 0 : 1); + expect(view?.render(40).map(stripAnsi).join("")).toContain(authUrl); + resolvePoll( + new Response( + JSON.stringify({ status: 0, state: "ready", uid: "123", credential: { api_key: "test-step-key" } }), + ), + ); + await expect(login).resolves.toMatchObject({ kind: "completed" }); + expect(readStepLoginCredential(authPath)).toMatchObject({ access: "test-step-key", profile: "step_plan" }); + } finally { + view?.handleInput("\x03"); + resolvePoll(new Response(JSON.stringify({ status: 0, state: "failed", reason: "denied" }))); + await login; + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/coding-agent/test/step-onboarding.test.ts b/packages/coding-agent/test/step-onboarding.test.ts index b489a444..8d530e5e 100644 --- a/packages/coding-agent/test/step-onboarding.test.ts +++ b/packages/coding-agent/test/step-onboarding.test.ts @@ -1,7 +1,9 @@ +import { visibleWidth } from "@step-harness/pi-tui"; import { describe, expect, it } from "vitest"; import { INITIAL_STEP_LOGIN_STEP, reduceStepLogin, resolveStepLoginProfiles } from "../src/step/onboarding.ts"; import { StepOnboardingView } from "../src/step/onboarding-view.ts"; import { initTheme } from "../src/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; describe("Step login onboarding", () => { it("offers the four Step login profiles in StepCode order", () => { @@ -58,8 +60,16 @@ describe("Step login onboarding", () => { choice: "step_plan_oversea", }); - expect(browser).toEqual({ kind: "continueInBrowser", choice: "step_plan", authUrl: "" }); - expect(overseaBrowser).toEqual({ kind: "continueInBrowser", choice: "step_plan_oversea", authUrl: "" }); + expect(browser).toEqual({ + kind: "continueInBrowser", + choice: "step_plan", + authUrl: "", + }); + expect(overseaBrowser).toEqual({ + kind: "continueInBrowser", + choice: "step_plan_oversea", + authUrl: "", + }); expect(apiKey).toEqual({ kind: "apiKeyEntry", choice: "platform_cn", value: "", error: null }); expect(reduceStepLogin(apiKey, { type: "credential", apiKey: "platform-key" })).toEqual({ kind: "saving", @@ -86,4 +96,25 @@ describe("Step login onboarding", () => { expect(rendered).toContain("4. Step Platform Oversea"); expect(rendered).toContain("Usage included with Mini, Plus, Pro, and Max plans"); }); + + it.each([12, 40, 80, 160])("shows the complete authorization URL at width %s", (width) => { + const view = new StepOnboardingView(resolveStepLoginProfiles(), { + onChoose: () => {}, + onSubmitApiKey: () => {}, + onType: () => {}, + onBackspace: () => {}, + onBack: () => {}, + onQuit: () => {}, + requestRender: () => {}, + }); + const authUrl = `https://platform.stepfun.com/cli-login?flow_id=${"a".repeat(32)}&extra=${"b".repeat(80)}`; + view.setStep({ + kind: "continueInBrowser", + choice: "step_plan", + authUrl, + }); + const rows = view.render(width); + expect(rows.map(stripAnsi).join("")).toContain(authUrl); + expect(rows.every((row) => visibleWidth(row) <= width)).toBe(true); + }); }); diff --git a/packages/coding-agent/test/step-provider.test.ts b/packages/coding-agent/test/step-provider.test.ts index 59814833..ba77a356 100644 --- a/packages/coding-agent/test/step-provider.test.ts +++ b/packages/coding-agent/test/step-provider.test.ts @@ -7,10 +7,7 @@ import { AuthStorage } from "../src/core/auth-storage.ts"; import type { ExtensionAPI, InlineExtension } from "../src/core/extensions/types.ts"; import { ModelRuntime } from "../src/core/model-runtime.ts"; import { InMemoryCodingAgentModelsStore } from "../src/core/models-store.ts"; -import type { StepCallbackResult } from "../src/features/step-provider/index.ts"; import { - buildStepAuthorizationUrl, - buildStepCallbackUrl, createStepProviderConfig, createStepProviderInlineExtension, fetchStepModelEfforts, @@ -21,17 +18,12 @@ import { STEP_PROVIDER_ENV, STEP_PROVIDER_ID, STEP_STATIC_REFRESH_TOKEN, - startStepCallbackServer, stepModelsDetailBaseUrl, stepOpenAiBaseUrl, stepProviderInlineExtension, stepThinkingLevelMap, } from "../src/features/step-provider/index.ts"; -async function request(port: number, path: string): Promise { - return fetch(`http://127.0.0.1:${port}${path}`); -} - function callbacks(overrides: Partial = {}): OAuthLoginCallbacks { return { onAuth: vi.fn(), @@ -42,59 +34,6 @@ function callbacks(overrides: Partial = {}): OAuthLoginCall }; } -describe("Step OAuth callback server", () => { - it("accepts a credential only when state matches", async () => { - const server = await startStepCallbackServer({ state: "state-1", timeoutMs: 5000 }); - try { - const invalid = await request(server.port, "/callback?state=wrong&api_key=ignored"); - expect(invalid.status).toBe(400); - - const resultPromise = server.waitForResult(); - const valid = await request(server.port, "/callback?state=state-1&api_key=key%20123&uid=user-1"); - expect(valid.status).toBe(200); - expect(await resultPromise).toEqual({ - kind: "credential", - apiKey: "key 123", - uid: "user-1", - }); - } finally { - await server.close(); - } - }); - - it("reports cancellation and timeout and closes idempotently", async () => { - const controller = new AbortController(); - const cancelled = await startStepCallbackServer({ state: "cancel", signal: controller.signal, timeoutMs: 5000 }); - const cancelledResult = cancelled.waitForResult(); - controller.abort(); - expect(await cancelledResult).toEqual({ kind: "cancelled" }); - await cancelled.close(); - await cancelled.close(); - - const timedOut = await startStepCallbackServer({ state: "timeout", timeoutMs: 5 }); - await expect(timedOut.waitForResult()).resolves.toEqual({ kind: "timeout" }); - await timedOut.close(); - }); - - it("rejects non-loopback hosts and malformed state", async () => { - await expect(startStepCallbackServer({ state: "" })).rejects.toThrow("state"); - await expect(startStepCallbackServer({ state: "ok", host: "0.0.0.0" })).rejects.toThrow("loopback"); - }); - - it("builds authorization and callback URLs with encoded state", () => { - const authUrl = buildStepAuthorizationUrl({ - authBaseUrl: "https://auth.example.test/root", - port: 4321, - state: "a state", - }); - const parsed = new URL(authUrl); - expect(parsed.pathname).toBe("/cli-login"); - expect(parsed.searchParams.get("port")).toBe("4321"); - expect(parsed.searchParams.get("state")).toBe("a state"); - expect(buildStepCallbackUrl({ host: "::1", port: 4321 })).toBe("http://[::1]:4321/callback"); - }); -}); - describe("Step provider extension", () => { afterEach(() => { vi.restoreAllMocks(); @@ -105,15 +44,11 @@ describe("Step provider extension", () => { env: { [STEP_PROVIDER_ENV.apiBaseUrl]: "https://api.example.test/v1/", [STEP_PROVIDER_ENV.authBaseUrl]: "https://auth.example.test/", - [STEP_PROVIDER_ENV.tokenUrl]: "https://auth.example.test/token", - [STEP_PROVIDER_ENV.callbackPort]: "3210", [STEP_PROVIDER_ENV.timeoutMs]: "9000", }, }); expect(options.apiBaseUrl).toBe("https://api.example.test"); expect(options.authBaseUrl).toBe("https://auth.example.test"); - expect(options.tokenUrl).toBe("https://auth.example.test/token"); - expect(options.callbackPort).toBe(3210); expect(options.timeoutMs).toBe(9000); }); @@ -350,121 +285,49 @@ describe("Step provider extension", () => { } }); - it("converts a callback credential into OAuth credentials", async () => { - const callback: StepCallbackResult = { - kind: "credential", - apiKey: "step-key", - uid: "u-1", - }; - const server = { - port: 4321, - waitForResult: vi.fn(async () => callback), - close: vi.fn(async () => {}), - }; + it("converts a cloud credential into the provider storage format", async () => { const onAuth = vi.fn(); - const result = await loginStepOAuth( - callbacks({ onAuth }), - resolveStepProviderOptions({ - authBaseUrl: "https://auth.example.test", - createState: () => "fixed-state", - createCallbackServer: async () => server, - }), - ); - expect(result.access).toBe("step-key"); - expect(result.refresh).toBe("step-static-credential"); - expect(result.uid).toBe("u-1"); - expect(onAuth).toHaveBeenCalledWith( - expect.objectContaining({ - url: "https://auth.example.test/cli-login?port=4321&state=fixed-state", - }), - ); - expect(server.close).toHaveBeenCalledTimes(1); - }); - - it("exchanges a callback code and uses the actual ephemeral port", async () => { - const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { - expect(init?.body).toBeInstanceOf(URLSearchParams); - const body = init?.body as URLSearchParams; - expect(body.get("redirect_uri")).toBe("http://127.0.0.1:5432/callback"); - return new Response( - JSON.stringify({ access_token: "access", refresh_token: "refresh", expires_in: 3600, uid: "u-token" }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ); + const result = await loginStepOAuth(callbacks({ onAuth }), { + authBaseUrl: "https://auth.example.test", + fetch: async (url) => + new Response( + JSON.stringify( + String(url).endsWith("/init") + ? { + status: 0, + flow_id: "flow-1", + poll_interval_sec: 2, + expires_at: Date.now() / 1000 + 600, + } + : { status: 0, state: "ready", uid: "u-1", credential: { api_key: "step-key" } }, + ), + ), }); - const server = { - port: 5432, - waitForResult: vi.fn(async () => ({ kind: "code", code: "auth-code" }) as const), - close: vi.fn(async () => {}), - }; - const result = await loginStepOAuth( - callbacks(), - resolveStepProviderOptions({ - authBaseUrl: "https://auth.example.test", - tokenUrl: "https://auth.example.test/token", - createState: () => "fixed-state", - createCallbackServer: async () => server, - fetch: fetchMock, - }), - ); - expect(result.access).toBe("access"); - expect(result.refresh).toBe("refresh"); - expect(result.uid).toBe("u-token"); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("preserves uid from a manually pasted callback URL", async () => { - const server = { - port: 5433, - waitForResult: vi.fn(() => new Promise(() => {})), - close: vi.fn(async () => {}), - }; - const result = await loginStepOAuth( - callbacks({ - onManualCodeInput: async () => - "http://127.0.0.1:5433/callback?state=fixed-state&api_key=access&uid=u-manual", - }), - resolveStepProviderOptions({ - authBaseUrl: "https://auth.example.test", - allowManualCallback: true, - createState: () => "fixed-state", - createCallbackServer: async () => server, - }), + expect(result).toEqual({ + access: "step-key", + refresh: STEP_STATIC_REFRESH_TOKEN, + expires: Number.MAX_SAFE_INTEGER, + uid: "u-1", + }); + expect(onAuth).toHaveBeenCalledWith( + expect.objectContaining({ url: "https://auth.example.test/cli-login-remote?flow_id=flow-1" }), ); - expect(result).toMatchObject({ access: "access", uid: "u-manual" }); - expect(server.close).toHaveBeenCalledTimes(1); }); it("does not refresh static Step keys", async () => { const credential = { access: "key", refresh: STEP_STATIC_REFRESH_TOKEN, expires: 0 }; - await expect(refreshStepOAuth(credential, { env: {} })).resolves.toMatchObject({ + await expect(refreshStepOAuth(credential)).resolves.toMatchObject({ access: "key", expires: Number.MAX_SAFE_INTEGER, }); }); - it("retains the account uid when a refresh response omits it", async () => { - const fetchMock = vi.fn( - async () => - new Response( - JSON.stringify({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ), - ); - const result = await refreshStepOAuth( - { access: "old-access", refresh: "old-refresh", expires: 1, uid: "u-existing" }, - { - env: {}, - tokenUrl: "https://auth.example.test/token", - fetch: fetchMock, - }, + it("requires a new login instead of refreshing legacy non-static tokens", async () => { + const fetch = vi.spyOn(globalThis, "fetch"); + await expect(refreshStepOAuth({ access: "old-access", refresh: "old-refresh", expires: 1 })).rejects.toThrow( + /step login/u, ); - expect(result).toMatchObject({ access: "new-access", uid: "u-existing" }); + expect(fetch).not.toHaveBeenCalled(); }); }); diff --git a/packages/providers/src/step-provider/callback-server.ts b/packages/providers/src/step-provider/callback-server.ts deleted file mode 100644 index db692819..00000000 --- a/packages/providers/src/step-provider/callback-server.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { createServer, type Server, type ServerResponse } from "node:http"; - -/** Callback route used by the Step developer-center login page. */ -export const STEP_OAUTH_CALLBACK_PATH = "/callback"; - -/** Optional route that lets a browser cancel a pending login. */ -export const STEP_OAUTH_CANCEL_PATH = "/cancel"; - -/** Ten minutes is long enough for a user to finish a browser login. */ -export const DEFAULT_STEP_OAUTH_TIMEOUT_MS = 10 * 60 * 1000; - -export const STEP_OAUTH_ERROR_CODES = ["no_access_key", "access_denied", "bad_request", "server_error"] as const; - -export type StepOAuthErrorCode = (typeof STEP_OAUTH_ERROR_CODES)[number]; - -export type StepCallbackResult = - | { - readonly kind: "credential"; - readonly apiKey: string; - readonly uid?: string; - readonly refreshToken?: string; - readonly expiresInSeconds?: number; - } - | { readonly kind: "code"; readonly code: string } - | { readonly kind: "error"; readonly code: StepOAuthErrorCode | "unknown"; readonly description?: string } - | { readonly kind: "cancelled" } - | { readonly kind: "timeout" }; - -export interface StepCallbackServer { - /** Port selected by the operating system (or the requested fixed port). */ - readonly port: number; - /** Resolves once, on the first valid callback, cancellation, or timeout. */ - waitForResult(): Promise; - /** Idempotent cleanup. Safe while `waitForResult()` is pending. */ - close(): Promise; -} - -export interface StartStepCallbackServerOptions { - /** State generated for this login; callbacks must echo it exactly. */ - readonly state: string; - /** Loopback host. Defaults to `127.0.0.1`. */ - readonly host?: string; - /** `0` asks the OS for an available port. */ - readonly port?: number; - readonly timeoutMs?: number; - readonly signal?: AbortSignal; - /** Injected clocks make timeout behavior deterministic in tests. */ - readonly setTimeoutFn?: SetTimeoutFn; - readonly clearTimeoutFn?: ClearTimeoutFn; -} - -type TimerHandle = ReturnType; -type SetTimeoutFn = (callback: () => void, delay: number) => TimerHandle; -type ClearTimeoutFn = (timer: TimerHandle) => void; - -/** - * Start a loopback callback server for the Step browser login. - * - * The server accepts only the expected state. Invalid-state requests receive a - * response but cannot settle the login promise, so a stray request can never - * authenticate a different login attempt. - */ -export async function startStepCallbackServer(options: StartStepCallbackServerOptions): Promise { - assertState(options.state); - const host = options.host ?? "127.0.0.1"; - assertLoopbackHost(host); - const port = options.port ?? 0; - assertPort(port); - - const setTimeoutFn = options.setTimeoutFn ?? ((callback, delay) => setTimeout(callback, delay)); - const clearTimeoutFn = options.clearTimeoutFn ?? ((timer) => clearTimeout(timer)); - - let settle: ((result: StepCallbackResult) => void) | undefined; - let settled = false; - const resultPromise = new Promise((resolve) => { - settle = resolve; - }); - const finish = (result: StepCallbackResult): void => { - if (settled) return; - settled = true; - settle?.(result); - }; - - const onAbort = (): void => finish({ kind: "cancelled" }); - if (options.signal?.aborted) { - finish({ kind: "cancelled" }); - } else { - options.signal?.addEventListener("abort", onAbort, { once: true }); - } - - const server = createServer((request, response) => { - try { - const url = new URL(request.url ?? "/", "http://127.0.0.1"); - if (url.pathname === STEP_OAUTH_CANCEL_PATH) { - respondText(response, 200, "Sign-in cancelled. You can close this tab."); - finish({ kind: "cancelled" }); - return; - } - - if (url.pathname !== STEP_OAUTH_CALLBACK_PATH) { - respondText(response, 404, "Not found."); - return; - } - - if (url.searchParams.get("state") !== options.state) { - respondText(response, 400, "This sign-in link is no longer valid."); - return; - } - - const error = readNonEmpty(url.searchParams.get("error")); - if (error) { - respondText(response, 400, "Sign-in did not complete. Return to your terminal."); - finish({ - kind: "error", - code: isStepOAuthErrorCode(error) ? error : "unknown", - description: readNonEmpty(url.searchParams.get("error_description")), - }); - return; - } - - const apiKey = firstNonEmpty(url.searchParams, ["api_key", "apiKey"]); - const accessToken = firstNonEmpty(url.searchParams, ["access_token", "accessToken"]); - if (apiKey || accessToken) { - respondText(response, 200, "Sign-in complete. You can close this tab."); - const uid = readBoundedUid(url.searchParams.get("uid")); - const refreshToken = firstNonEmpty(url.searchParams, ["refresh_token", "refreshToken"]); - const expiresInSeconds = readPositiveNumber(firstNonEmpty(url.searchParams, ["expires_in", "expiresIn"])); - finish({ - kind: "credential", - apiKey: apiKey ?? accessToken!, - ...(uid ? { uid } : undefined), - ...(refreshToken ? { refreshToken } : undefined), - ...(expiresInSeconds ? { expiresInSeconds } : undefined), - }); - return; - } - - const code = firstNonEmpty(url.searchParams, ["code"]); - if (code) { - respondText(response, 200, "Sign-in callback received. You can close this tab."); - finish({ kind: "code", code }); - return; - } - - respondText(response, 400, "Missing sign-in result."); - finish({ kind: "error", code: "unknown", description: "The callback did not contain a credential." }); - } catch { - respondText(response, 500, "Sign-in callback failed."); - } - }); - - let timer: TimerHandle | undefined; - let closed: Promise | undefined; - const removeAbortListener = (): void => options.signal?.removeEventListener("abort", onAbort); - - try { - await listen(server, host, port); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("Unable to determine the Step OAuth callback port"); - } - - timer = setTimeoutFn(() => finish({ kind: "timeout" }), options.timeoutMs ?? DEFAULT_STEP_OAUTH_TIMEOUT_MS); - unrefTimer(timer); - - const close = async (): Promise => { - if (!closed) { - clearTimeoutFn(timer!); - removeAbortListener(); - finish({ kind: "cancelled" }); - closed = closeServer(server); - } - await closed; - }; - - return { - port: address.port, - waitForResult: async () => { - try { - return await resultPromise; - } finally { - if (timer) clearTimeoutFn(timer); - removeAbortListener(); - } - }, - close, - }; - } catch (error) { - removeAbortListener(); - if (timer) clearTimeoutFn(timer); - await closeServer(server); - throw error; - } -} - -/** Build the developer-center URL that starts a Step login. */ -export function buildStepAuthorizationUrl(input: { - readonly authBaseUrl: string; - readonly port: number; - readonly state: string; - readonly path?: string; -}): string { - assertState(input.state); - assertPort(input.port); - const base = parseHttpUrl(input.authBaseUrl, "Step OAuth authorization endpoint"); - const path = input.path ?? "/cli-login"; - if (!path.startsWith("/")) throw new Error("Step OAuth authorization path must start with '/'"); - const url = new URL(path, `${base.origin}/`); - url.searchParams.set("port", String(input.port)); - url.searchParams.set("state", input.state); - return url.toString(); -} - -/** Build the redirect URI represented by a callback server. */ -export function buildStepCallbackUrl(input: { - readonly host?: string; - readonly port: number; - readonly path?: string; -}): string { - const host = input.host ?? "127.0.0.1"; - assertLoopbackHost(host); - assertPort(input.port); - const path = input.path ?? STEP_OAUTH_CALLBACK_PATH; - if (!path.startsWith("/")) throw new Error("Step OAuth callback path must start with '/'"); - return `http://${formatHost(host)}:${input.port}${path}`; -} - -function listen(server: Server, host: string, port: number): Promise { - return new Promise((resolve, reject) => { - const onError = (error: Error): void => { - server.off("listening", onListening); - reject(error); - }; - const onListening = (): void => { - server.off("error", onError); - resolve(); - }; - server.once("error", onError); - server.once("listening", onListening); - server.listen(port, host); - }); -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => { - if (!server.listening) { - resolve(); - return; - } - server.close(() => resolve()); - server.closeAllConnections?.(); - }); -} - -function respondText(response: ServerResponse, status: number, body: string): void { - if (response.headersSent) return; - response.writeHead(status, { - "content-type": "text/plain; charset=utf-8", - "cache-control": "no-store", - }); - response.end(`${body}\n`); -} - -function firstNonEmpty(params: URLSearchParams, names: readonly string[]): string | undefined { - for (const name of names) { - const value = readNonEmpty(params.get(name)); - if (value) return value; - } - return undefined; -} - -function readNonEmpty(value: string | null): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - -function readPositiveNumber(value: string | undefined): number | undefined { - if (!value) return undefined; - const parsed = Number(value); - return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; -} - -function readBoundedUid(value: string | null): string | undefined { - const trimmed = readNonEmpty(value); - if (!trimmed || trimmed.length > 64 || !/^[\w.@:-]+$/u.test(trimmed)) return undefined; - return trimmed; -} - -function isStepOAuthErrorCode(value: string): value is StepOAuthErrorCode { - return (STEP_OAUTH_ERROR_CODES as readonly string[]).includes(value); -} - -function parseHttpUrl(value: string, label: string): URL { - let url: URL; - try { - url = new URL(value); - } catch { - throw new Error(`${label} must be a valid URL`); - } - if (url.protocol !== "http:" && url.protocol !== "https:") { - throw new Error(`${label} must use http or https`); - } - if (url.username || url.password) throw new Error(`${label} must not contain credentials`); - return url; -} - -function assertState(state: string): void { - if (!state.trim()) throw new Error("Step OAuth state must not be empty"); -} - -function assertPort(port: number): void { - if (!Number.isInteger(port) || port < 0 || port > 65535) { - throw new Error("Step OAuth callback port must be an integer between 0 and 65535"); - } -} - -function assertLoopbackHost(host: string): void { - if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") { - throw new Error("Step OAuth callback host must be loopback"); - } -} - -function formatHost(host: string): string { - return host.includes(":") ? `[${host}]` : host; -} - -function unrefTimer(timer: TimerHandle): void { - if (typeof timer === "object" && timer !== null && "unref" in timer) { - (timer as { unref?: () => void }).unref?.(); - } -} diff --git a/packages/providers/src/step-provider/index.ts b/packages/providers/src/step-provider/index.ts index d5e622f3..d8fbeba4 100644 --- a/packages/providers/src/step-provider/index.ts +++ b/packages/providers/src/step-provider/index.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { randomBytes } from "node:crypto"; import process from "node:process"; import type { Credential, OAuthCredentials } from "../auth/types.ts"; import type { OAuthLoginCallbacks } from "../compat/extension-oauth-types.ts"; @@ -27,32 +27,32 @@ export interface StepModelConfig { } import { - buildStepAuthorizationUrl, - buildStepCallbackUrl, - DEFAULT_STEP_OAUTH_TIMEOUT_MS, - STEP_OAUTH_CALLBACK_PATH, - type StartStepCallbackServerOptions, - type StepCallbackResult, - type StepCallbackServer, - type StepOAuthErrorCode, - startStepCallbackServer, -} from "./callback-server.ts"; + defaultStepCliClientInfo, + initStepCliLogin, + type StepCliClientInfo, + withStepCliLoginTimeout, +} from "./login-client.ts"; +import { waitForStepCliLogin } from "./login-polling.ts"; export type { - StartStepCallbackServerOptions, - StepCallbackResult, - StepCallbackServer, - StepOAuthErrorCode, -} from "./callback-server.ts"; + InitStepCliLoginInput, + PollStepCliLoginInput, + StepCliClientInfo, + StepCliLoginErrorKind, + StepCliLoginFailureReason, + StepCliLoginInit, + StepCliLoginPoll, +} from "./login-client.ts"; export { - buildStepAuthorizationUrl, - buildStepCallbackUrl, - DEFAULT_STEP_OAUTH_TIMEOUT_MS, - STEP_OAUTH_CALLBACK_PATH, - STEP_OAUTH_CANCEL_PATH, - STEP_OAUTH_ERROR_CODES, - startStepCallbackServer, -} from "./callback-server.ts"; + defaultStepCliClientInfo, + initStepCliLogin, + isRetryableStepCliLoginError, + pollStepCliLogin, + STEP_CLI_LOGIN_PATH_PREFIX, + StepCliLoginRequestError, +} from "./login-client.ts"; +export type { StepCliLoginReady, WaitForStepCliLoginInput } from "./login-polling.ts"; +export { MIN_STEP_POLL_INTERVAL_MS, waitForStepCliLogin } from "./login-polling.ts"; /** Provider id used by the default Step extension. */ export const STEP_PROVIDER_ID = "step"; @@ -61,24 +61,20 @@ export const STEP_PROVIDER_ID = "step"; export const STEP_PROVIDER_ENV = { apiBaseUrl: "STEP_BASE_URL", authBaseUrl: "STEPCODE_DEVCENTER_AUTH_CN_URL", - tokenUrl: "STEP_OAUTH_TOKEN_URL", apiKey: "STEP_API_KEY", - callbackHost: "STEP_OAUTH_CALLBACK_HOST", - callbackPort: "STEP_OAUTH_CALLBACK_PORT", timeoutMs: "STEP_OAUTH_TIMEOUT_MS", - clientId: "STEP_OAUTH_CLIENT_ID", - scope: "STEP_OAUTH_SCOPE", } as const; +/** Maximum time for a cloud sign-in, including init and polling. */ +export const DEFAULT_STEP_CLI_LOGIN_TIMEOUT_MS = 10 * 60 * 1000; + /** Production defaults; every endpoint can be replaced through the options/env. */ export const STEP_PROVIDER_DEFAULTS = { // The OpenAI adapter appends `/chat/completions` to `{baseUrl}/v1`, so this // must be the route prefix rather than a versioned operation URL. apiBaseUrl: "https://api.stepfun.com/step_plan", authBaseUrl: "https://platform.stepfun.com", - callbackHost: "127.0.0.1", - callbackPort: 0, - timeoutMs: DEFAULT_STEP_OAUTH_TIMEOUT_MS, + timeoutMs: DEFAULT_STEP_CLI_LOGIN_TIMEOUT_MS, } as const; /** Marker used for credentials that contain a non-expiring Step API key. */ @@ -170,28 +166,21 @@ const AUTH_BASE_URL_ENV_NAMES = [ "STEP_LOGIN_PROFILE_AUTH_URL", STEP_PROVIDER_ENV.authBaseUrl, ] as const; -const REFRESH_SKEW_MS = 5 * 60 * 1000; -const MAX_ERROR_DETAIL_LENGTH = 240; export interface StepProviderOptions { readonly providerId?: string; readonly name?: string; readonly apiBaseUrl?: string; readonly authBaseUrl?: string; - readonly tokenUrl?: string; readonly apiKeyEnv?: string; - readonly callbackHost?: string; - readonly callbackPort?: number; readonly timeoutMs?: number; - readonly clientId?: string; - readonly scope?: string; readonly env?: Record; readonly models?: readonly StepModelConfig[]; - readonly createState?: () => string; - readonly createCallbackServer?: CallbackServerFactory; readonly fetch?: typeof fetch; - /** Enable a terminal prompt for hosts where the browser cannot reach loopback. */ - readonly allowManualCallback?: boolean; + /** Login profile metadata recorded by the backend. */ + readonly loginProfile?: string; + /** Terminal metadata for server audit logs. Never used for auth. */ + readonly client?: StepCliClientInfo; } export interface ResolvedStepProviderOptions { @@ -199,23 +188,15 @@ export interface ResolvedStepProviderOptions { readonly name: string; readonly apiBaseUrl: string; readonly authBaseUrl: string; - readonly tokenUrl?: string; readonly apiKeyEnv: string; - readonly callbackHost: string; - readonly callbackPort: number; readonly timeoutMs: number; - readonly clientId?: string; - readonly scope?: string; readonly env: Record; readonly models: readonly StepModelConfig[]; - readonly createState: () => string; - readonly createCallbackServer: CallbackServerFactory; readonly fetch?: typeof fetch; - readonly allowManualCallback: boolean; + readonly loginProfile: string; + readonly client: StepCliClientInfo; } -export type CallbackServerFactory = (options: StartStepCallbackServerOptions) => Promise; - /** Resolve provider settings at registration time, after environment setup. */ export function resolveStepProviderOptions(options: StepProviderOptions = {}): ResolvedStepProviderOptions { const env = options.env ?? process.env; @@ -229,8 +210,6 @@ export function resolveStepProviderOptions(options: StepProviderOptions = {}): R options.authBaseUrl ?? readFirstEnv(env, AUTH_BASE_URL_ENV_NAMES) ?? STEP_PROVIDER_DEFAULTS.authBaseUrl, "Step OAuth authorization URL", ); - const tokenUrlValue = options.tokenUrl ?? readFirstEnv(env, [STEP_PROVIDER_ENV.tokenUrl]); - const tokenUrl = tokenUrlValue ? validateHttpEndpoint(tokenUrlValue, "Step OAuth token URL") : undefined; const providerId = nonEmpty(options.providerId ?? STEP_PROVIDER_ID, "Step provider id"); const name = nonEmpty(options.name ?? "Step", "Step provider name"); const apiKeyEnv = nonEmpty(options.apiKeyEnv ?? STEP_PROVIDER_ENV.apiKey, "Step API key environment variable"); @@ -238,18 +217,11 @@ export function resolveStepProviderOptions(options: StepProviderOptions = {}): R throw new Error(`Invalid Step API key environment variable name: ${apiKeyEnv}`); } - const callbackHost = - options.callbackHost ?? - readFirstEnv(env, [STEP_PROVIDER_ENV.callbackHost]) ?? - STEP_PROVIDER_DEFAULTS.callbackHost; - const callbackPort = - options.callbackPort ?? readPortEnv(env[STEP_PROVIDER_ENV.callbackPort]) ?? STEP_PROVIDER_DEFAULTS.callbackPort; const timeoutMs = options.timeoutMs ?? readPositiveInteger(env[STEP_PROVIDER_ENV.timeoutMs]) ?? STEP_PROVIDER_DEFAULTS.timeoutMs; - if (timeoutMs <= 0) throw new Error("Step OAuth timeout must be greater than zero"); + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) + throw new Error("Step login timeout must be finite and greater than zero"); - const clientId = options.clientId ?? readFirstEnv(env, [STEP_PROVIDER_ENV.clientId]); - const scope = options.scope ?? readFirstEnv(env, [STEP_PROVIDER_ENV.scope]); // Empty is allowed: the Step catalog is discovered dynamically from // `{base}/v1/models`; there is no built-in baseline. const models = options.models ?? STEP_MODELS; @@ -259,19 +231,15 @@ export function resolveStepProviderOptions(options: StepProviderOptions = {}): R name, apiBaseUrl, authBaseUrl, - tokenUrl, apiKeyEnv, - callbackHost, - callbackPort, timeoutMs, - clientId, - scope, env, models, - createState: options.createState ?? randomUUID, - createCallbackServer: options.createCallbackServer ?? startStepCallbackServer, fetch: options.fetch, - allowManualCallback: options.allowManualCallback ?? false, + loginProfile: + options.loginProfile?.trim() || + (new URL(authBaseUrl).hostname.endsWith(".ai") ? "step_plan_oversea" : "step_plan"), + client: options.client ?? defaultStepCliClientInfo("unknown"), }; } @@ -296,77 +264,60 @@ export function normalizeStepModel(model: Model, openaiBaseUrl: string): Mo return { ...model, api: STEP_MODEL_API, baseUrl: openaiBaseUrl }; } +/** Cloud login has one path: init, open the supplied URL, poll, store the existing API key. */ export async function loginStepOAuth( callbacks: OAuthLoginCallbacks, options: ResolvedStepProviderOptions | StepOAuthLoginOptions, ): Promise { - const resolved = isResolvedOptions(options) ? options : resolveStepProviderOptions(options); - const state = nonEmpty(resolved.createState(), "Step OAuth state"); - const server = await resolved.createCallbackServer({ - state, - host: resolved.callbackHost, - port: resolved.callbackPort, - timeoutMs: resolved.timeoutMs, - signal: callbacks.signal, - }); - - try { - const authUrl = buildStepAuthorizationUrl({ - authBaseUrl: resolved.authBaseUrl, - port: server.port, - state, - }); - callbacks.onAuth({ - url: authUrl, - instructions: "Complete sign-in in your browser. The terminal will continue automatically.", - }); - - const result = resolved.allowManualCallback - ? await waitForCallbackOrManualInput(server, callbacks, state) - : await server.waitForResult(); - if (result.kind === "credential") return credentialFromCallback(result); - if (result.kind === "code") { - return exchangeAuthorizationCode(result.code, state, server.port, resolved, callbacks.signal); - } - if (result.kind === "error") { - const detail = result.description ? `: ${result.description}` : ""; - throw new Error(`Step OAuth login failed (${result.code})${detail}`); - } - if (result.kind === "timeout") throw new Error("Step OAuth login timed out"); - throw new Error("Step OAuth login cancelled"); - } finally { - await server.close(); - } + const resolved = resolveStepProviderOptions(options); + return withStepCliLoginTimeout( + resolved.timeoutMs, + callbacks.signal, + () => new Error("Step sign-in timed out. Run `step login` again."), + async (signal) => { + const pollToken = randomBytes(32).toString("hex"); + const init = await initStepCliLogin({ + authBaseUrl: resolved.authBaseUrl, + pollToken, + profile: resolved.loginProfile, + client: resolved.client, + signal, + fetch: resolved.fetch, + }); + signal.throwIfAborted(); + callbacks.onAuth({ + url: init.authorizeUrl, + instructions: "Open this URL in any browser and approve the sign-in.", + }); + callbacks.onProgress?.("Waiting for browser confirmation..."); + const ready = await waitForStepCliLogin({ + init, + authBaseUrl: resolved.authBaseUrl, + pollToken, + signal, + fetch: resolved.fetch, + }); + signal.throwIfAborted(); + return { + access: ready.apiKey, + refresh: STEP_STATIC_REFRESH_TOKEN, + expires: Number.MAX_SAFE_INTEGER, + ...(ready.uid ? { uid: ready.uid } : {}), + }; + }, + ); } export interface StepOAuthLoginOptions extends StepProviderOptions { readonly authBaseUrl: string; } -export async function refreshStepOAuth( - credentials: OAuthCredentials, - options: ResolvedStepProviderOptions | StepProviderOptions = {}, - signal?: AbortSignal, -): Promise { - const resolved = isResolvedOptions(options) ? options : resolveStepProviderOptions(options); - if (credentials.refresh === STEP_STATIC_REFRESH_TOKEN) { - return { ...credentials, expires: Number.MAX_SAFE_INTEGER }; +// The provider interface calls this for expired credentials; current Step keys do not expire locally. +export async function refreshStepOAuth(credentials: OAuthCredentials): Promise { + if (credentials.refresh !== STEP_STATIC_REFRESH_TOKEN) { + throw new Error("This Step credential cannot be refreshed. Run `step login` again."); } - if (!credentials.refresh) throw new Error("Step OAuth credentials do not contain a refresh token"); - if (!resolved.tokenUrl) throw new Error("Step OAuth credentials have expired and no token endpoint is configured"); - const refreshed = await requestToken( - resolved.tokenUrl, - new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: credentials.refresh, - ...(resolved.clientId ? { client_id: resolved.clientId } : undefined), - }), - resolved, - credentials.refresh, - signal, - ); - const uid = readBoundedUid(readString(credentials, "uid")); - return uid && !readBoundedUid(readString(refreshed, "uid")) ? { ...refreshed, uid } : refreshed; + return { ...credentials, expires: Number.MAX_SAFE_INTEGER }; } export function getStepOAuthApiKey(credentials: OAuthCredentials): string { @@ -375,94 +326,6 @@ export function getStepOAuthApiKey(credentials: OAuthCredentials): string { return access; } -function isResolvedOptions( - options: StepProviderOptions | ResolvedStepProviderOptions, -): options is ResolvedStepProviderOptions { - return ( - "providerId" in options && - "name" in options && - "apiBaseUrl" in options && - "authBaseUrl" in options && - "apiKeyEnv" in options && - "callbackHost" in options && - "callbackPort" in options && - "timeoutMs" in options && - "env" in options && - "models" in options && - "createState" in options && - "createCallbackServer" in options && - "allowManualCallback" in options - ); -} - -async function exchangeAuthorizationCode( - code: string, - state: string, - callbackPort: number, - options: ResolvedStepProviderOptions, - signal?: AbortSignal, -): Promise { - if (!options.tokenUrl) throw new Error("Step OAuth callback returned a code but no token endpoint is configured"); - const redirectUri = buildStepCallbackUrl({ - host: options.callbackHost, - port: callbackPort, - path: STEP_OAUTH_CALLBACK_PATH, - }); - return requestToken( - options.tokenUrl, - new URLSearchParams({ - grant_type: "authorization_code", - code, - state, - redirect_uri: redirectUri, - ...(options.clientId ? { client_id: options.clientId } : undefined), - ...(options.scope ? { scope: options.scope } : undefined), - }), - options, - undefined, - signal, - ); -} - -async function requestToken( - tokenUrl: string, - body: URLSearchParams, - options: ResolvedStepProviderOptions, - previousRefreshToken?: string, - signal?: AbortSignal, -): Promise { - const fetchFn = resolveFetch(options.fetch); - let response: Response; - try { - response = await fetchFn(tokenUrl, { - method: "POST", - headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, - body, - signal, - }); - } catch (error) { - if (signal?.aborted) throw new Error("Step OAuth login cancelled"); - throw new Error(`Step OAuth token request failed: ${describeError(error)}`); - } - - const payload = await readJsonObject(response); - if (!response.ok) { - throw new Error(`Step OAuth token request failed (HTTP ${response.status})${tokenErrorDetail(payload)}`); - } - - const access = readString(payload, "access_token") ?? readString(payload, "access"); - if (!access) throw new Error("Step OAuth token response did not contain an access token"); - const refresh = readString(payload, "refresh_token") ?? readString(payload, "refresh") ?? previousRefreshToken ?? ""; - const expiresIn = readPositiveNumber(payload, "expires_in") ?? readPositiveNumber(payload, "expires"); - const uid = readBoundedUid(readString(payload, "uid") ?? readString(payload, "user_id")); - return { - access, - refresh, - expires: expiresIn ? Date.now() + expiresIn * 1000 - REFRESH_SKEW_MS : Number.MAX_SAFE_INTEGER, - ...(uid ? { uid } : undefined), - }; -} - async function readJsonObject(response: Response): Promise> { let value: unknown; try { @@ -473,68 +336,6 @@ async function readJsonObject(response: Response): Promise) : {}; } -function readBoundedUid(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - if (!trimmed || trimmed.length > 64 || !/^[\w.@:-]+$/u.test(trimmed)) return undefined; - return trimmed; -} - -function credentialFromCallback(result: Extract): OAuthCredentials { - const expires = result.expiresInSeconds - ? Date.now() + result.expiresInSeconds * 1000 - REFRESH_SKEW_MS - : Number.MAX_SAFE_INTEGER; - return { - access: result.apiKey, - refresh: result.refreshToken ?? (result.expiresInSeconds ? "" : STEP_STATIC_REFRESH_TOKEN), - expires, - ...(result.uid ? { uid: result.uid } : undefined), - }; -} - -async function waitForCallbackOrManualInput( - server: StepCallbackServer, - callbacks: OAuthLoginCallbacks, - expectedState: string, -): Promise { - const callback = server.waitForResult(); - const manual = callbacks.onManualCodeInput - ? callbacks.onManualCodeInput() - : callbacks.onPrompt({ - message: "Paste the Step callback URL (or API key) if the browser cannot reach this terminal:", - }); - const manualResult = manual.then((value): StepCallbackResult => parseManualCallback(value, expectedState)); - return Promise.race([callback, manualResult]); -} - -function parseManualCallback(value: string, expectedState: string): StepCallbackResult { - const trimmed = value.trim(); - if (!trimmed) return { kind: "error", code: "unknown", description: "Empty callback input" }; - try { - const url = new URL(trimmed); - const state = url.searchParams.get("state")?.trim(); - if (state && state !== expectedState) { - return { kind: "error", code: "unknown", description: "OAuth state mismatch" }; - } - const error = url.searchParams.get("error")?.trim(); - if (error) { - return { - kind: "error", - code: isKnownErrorCode(error) ? error : "unknown", - description: url.searchParams.get("error_description")?.trim() || undefined, - }; - } - const apiKey = url.searchParams.get("api_key")?.trim() ?? url.searchParams.get("access_token")?.trim(); - if (apiKey) { - const uid = readBoundedUid(url.searchParams.get("uid") ?? undefined); - return { kind: "credential", apiKey, ...(uid ? { uid } : undefined) }; - } - const code = url.searchParams.get("code")?.trim(); - return code ? { kind: "code", code } : { kind: "error", code: "unknown", description: "Missing callback result" }; - } catch { - return { kind: "credential", apiKey: trimmed }; - } -} - function resolveFetch(fetchFn: typeof fetch | undefined): typeof fetch { if (fetchFn) return fetchFn; if (typeof globalThis.fetch !== "function") throw new Error("Step OAuth requires a fetch implementation"); @@ -555,15 +356,6 @@ function readPositiveInteger(value: string | undefined): number | undefined { return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; } -function readPortEnv(value: string | undefined): number | undefined { - if (value === undefined || value.trim() === "") return undefined; - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) { - throw new Error("STEP_OAUTH_CALLBACK_PORT must be an integer between 0 and 65535"); - } - return parsed; -} - function validateHttpEndpoint(value: string, label: string): string { let url: URL; try { @@ -794,18 +586,3 @@ function readStringArray(value: Record, key: string): string[] const items = result.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); return items.length > 0 ? items : undefined; } - -function tokenErrorDetail(value: Record): string { - const error = readString(value, "error"); - const description = readString(value, "error_description"); - const detail = [error, description].filter((part): part is string => Boolean(part)).join(": "); - return detail ? `: ${detail.slice(0, MAX_ERROR_DETAIL_LENGTH)}` : ""; -} - -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function isKnownErrorCode(value: string): value is StepOAuthErrorCode { - return ["no_access_key", "access_denied", "bad_request", "server_error"].includes(value as StepOAuthErrorCode); -} diff --git a/packages/providers/src/step-provider/login-client.ts b/packages/providers/src/step-provider/login-client.ts new file mode 100644 index 00000000..a31f94b3 --- /dev/null +++ b/packages/providers/src/step-provider/login-client.ts @@ -0,0 +1,382 @@ +import process from "node:process"; +import { raceWithAbortSignal } from "../utils/abort.ts"; +import { combineAbortSignals } from "../utils/abort-signals.ts"; + +/** + * Cloud-polled CLI login: the terminal asks the developer center to start a + * login flow, shows the user a URL, and then polls for the result. + * + * Nothing is pushed to the terminal: the CLI only makes outbound HTTPS requests and never listens on a + * port, so the flow survives SSH, containers and hosts without a browser. + * + * Two values with two different roles, which must never be conflated: + * - `flow_id` public identifier. It travels in the browser URL, so it ends + * up in history, screenshots and possibly Referer headers. It + * can never be redeemed for a credential on its own. + * - `poll_token` the only credential on the CLI side. It appears exclusively + * in the `Authorization` header and never in a URL, a log or + * on disk. + */ + +/** Route prefix served by the developer center (`authBaseUrl`). */ +export const STEP_CLI_LOGIN_PATH_PREFIX = "/api/stdhttp/v1/cli-login"; +const STEP_CLI_LOGIN_AUTHORIZE_PATH = "/cli-login-remote"; + +/** A stuck request must leave time for retries within the login deadline. */ +const REQUEST_TIMEOUT_MS = 15_000; + +/** + * Both responses are small JSON documents. Cap the body so a malicious or + * broken endpoint cannot exhaust the CLI's memory. + */ +const MAX_RESPONSE_BYTES = 64 * 1024; + +/** `flow_id` is interpolated into a request path; keep it to an opaque token. */ +const FLOW_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/u; + +/** A server may slow the CLI down, but not park it for minutes at a time. */ +const MAX_POLL_INTERVAL_SEC = 60; + +const MAX_ERROR_DETAIL_LENGTH = 240; + +/** Terminal metadata for server audit logs. Never used for authentication. */ +export interface StepCliClientInfo { + readonly name: string; + readonly version: string; + readonly platform: string; +} + +export interface StepCliLoginInit { + /** Public identifier; safe to print. */ + readonly flowId: string; + /** Always `https:`, always on the developer-center origin. */ + readonly authorizeUrl: string; + readonly pollIntervalSec: number; + /** Unix seconds; bounds the polling deadline. */ + readonly expiresAt: number; +} + +export type StepCliLoginFailureReason = "denied" | "unknown"; + +export type StepCliLoginPoll = + | { readonly state: "pending" } + | { + readonly state: "ready"; + readonly apiKey: string; + readonly uid?: string; + } + | { readonly state: "failed"; readonly reason: StepCliLoginFailureReason }; + +export type StepCliLoginErrorKind = + /** Non-2xx HTTP response, or a non-zero `status` field in a 2xx envelope. */ + | "http" + /** 2xx response whose shape or field values do not match the protocol. */ + | "protocol" + /** The request never produced a response. */ + | "transport"; + +export class StepCliLoginRequestError extends Error { + readonly kind: StepCliLoginErrorKind; + readonly httpStatus?: number; + + constructor( + kind: StepCliLoginErrorKind, + message: string, + options: { readonly httpStatus?: number; readonly cause?: unknown } = {}, + ) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = "StepCliLoginRequestError"; + this.kind = kind; + if (options.httpStatus !== undefined) this.httpStatus = options.httpStatus; + } +} + +/** Retry only on the statuses the protocol defines as transient. */ +export function isRetryableStepCliLoginError(error: unknown): boolean { + if (!(error instanceof StepCliLoginRequestError) || error.kind === "protocol") return false; + const status = error.httpStatus; + if (status === undefined) return error.kind === "transport"; + return status === 408 || status === 429 || status >= 500; +} + +/** Audit metadata derived from the running process. */ +export function defaultStepCliClientInfo(version: string): StepCliClientInfo { + return { + name: "stepcode", + version, + platform: `${process.platform}-${process.arch}`, + }; +} + +export interface InitStepCliLoginInput { + readonly authBaseUrl: string; + readonly pollToken: string; + readonly profile: string; + readonly client: StepCliClientInfo; + readonly signal?: AbortSignal; + readonly fetch?: typeof fetch; +} + +/** Start a login flow and obtain the URL to show the user. */ +export async function initStepCliLogin(input: InitStepCliLoginInput): Promise { + const base = parseAuthBaseUrl(input.authBaseUrl); + const payload = await request({ + phase: "init", + url: `${base.origin}${STEP_CLI_LOGIN_PATH_PREFIX}/init`, + method: "POST", + pollToken: input.pollToken, + body: { + profile: input.profile, + origin: base.origin, + client: input.client, + }, + signal: input.signal, + fetch: input.fetch, + }); + + const flowId = readString(payload, "flow_id"); + if (!flowId || !FLOW_ID_PATTERN.test(flowId)) { + throw protocolError("init", "response did not contain a usable flow_id"); + } + const authorizeUrl = new URL(STEP_CLI_LOGIN_AUTHORIZE_PATH, `${base.origin}/`); + authorizeUrl.searchParams.set("flow_id", flowId); + const pollIntervalSec = readNumber(payload, "poll_interval_sec"); + if (pollIntervalSec === undefined || pollIntervalSec < 1) { + throw protocolError("init", "response did not contain a poll_interval_sec of at least 1 second"); + } + const expiresAt = readNumber(payload, "expires_at"); + if (expiresAt === undefined || expiresAt <= 0) { + throw protocolError("init", "response did not contain an expires_at timestamp"); + } + + return { + flowId, + authorizeUrl: authorizeUrl.toString(), + pollIntervalSec: Math.min(pollIntervalSec, MAX_POLL_INTERVAL_SEC), + expiresAt, + }; +} + +export interface PollStepCliLoginInput { + readonly authBaseUrl: string; + readonly pollToken: string; + readonly flowId: string; + readonly signal?: AbortSignal; + readonly fetch?: typeof fetch; +} + +/** + * Read the current state of a flow. + * + * A successful poll normally deletes the flow. Persist the returned key and + * stop polling; the protocol does not promise at-most-once concurrent delivery. + */ +export async function pollStepCliLogin(input: PollStepCliLoginInput): Promise { + const base = parseAuthBaseUrl(input.authBaseUrl); + if (!FLOW_ID_PATTERN.test(input.flowId)) throw protocolError("poll", "flow id has an unexpected format"); + const payload = await request({ + phase: "poll", + url: `${base.origin}${STEP_CLI_LOGIN_PATH_PREFIX}/poll/${encodeURIComponent(input.flowId)}`, + method: "GET", + pollToken: input.pollToken, + signal: input.signal, + fetch: input.fetch, + }); + + const state = readString(payload, "state"); + if (state === "pending") return { state: "pending" }; + if (state === "failed") { + const reason = readString(payload, "reason"); + return { + state: "failed", + reason: reason === "denied" ? "denied" : "unknown", + }; + } + if (state !== "ready") throw protocolError("poll", "response contained an unknown state"); + + const credential = payload.credential; + if (!credential || typeof credential !== "object" || Array.isArray(credential)) { + throw protocolError("poll", "ready response did not contain a credential object"); + } + const apiKey = readString(credential as Record, "api_key"); + if (!apiKey) throw protocolError("poll", "ready response did not contain an api_key"); + const uid = readBoundedUid(readString(payload, "uid")); + return { + state: "ready", + apiKey, + ...(uid ? { uid } : {}), + }; +} + +interface RequestInput { + readonly phase: "init" | "poll"; + readonly url: string; + readonly method: "GET" | "POST"; + readonly pollToken: string; + readonly body?: unknown; + readonly signal?: AbortSignal; + readonly fetch?: typeof fetch; +} + +async function request(input: RequestInput): Promise> { + return withStepCliLoginTimeout( + REQUEST_TIMEOUT_MS, + input.signal, + () => new StepCliLoginRequestError("transport", "Step CLI login request timed out"), + async (signal) => { + const pollToken = input.pollToken.trim(); + if (!pollToken) throw new Error("Step CLI login poll token must not be empty"); + const fetchFn = input.fetch ?? globalThis.fetch.bind(globalThis); + let response: Response; + try { + response = await fetchFn(input.url, { + method: input.method, + redirect: "error", + credentials: "omit", + signal, + headers: { + accept: "application/json", + authorization: `Bearer ${pollToken}`, + ...(input.body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }), + }); + } catch (error) { + signal.throwIfAborted(); + throw new StepCliLoginRequestError( + "transport", + `Step CLI login ${input.phase} request failed: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + let text = ""; + try { + text = await readBoundedText(response, input.phase); + } catch (error) { + signal.throwIfAborted(); + // An error response's body is optional; HTTP status must still decide retries. + if (response.ok) { + if (error instanceof StepCliLoginRequestError) throw error; + throw new StepCliLoginRequestError("transport", "Step CLI login response was interrupted", { + cause: error, + }); + } + } + let parsed: unknown; + try { + parsed = text.trim() ? JSON.parse(text) : undefined; + } catch (error) { + if (response.ok) throw protocolError(input.phase, "response was not valid JSON", error); + } + if (!response.ok) { + throw new StepCliLoginRequestError( + "http", + `Step CLI login request failed (HTTP ${response.status})${envelopeDetail(parsed)}`, + { httpStatus: response.status }, + ); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + throw protocolError(input.phase, "response was not a JSON object"); + const payload = parsed as Record; + if (payload.status !== undefined && payload.status !== 0) { + throw new StepCliLoginRequestError("http", `Step CLI login was rejected${envelopeDetail(payload)}`, { + httpStatus: response.status, + }); + } + return payload; + }, + ); +} + +/** Read at most {@link MAX_RESPONSE_BYTES}, refusing anything larger. */ +async function readBoundedText(response: Response, phase: "init" | "poll"): Promise { + const declared = Number(response.headers.get("content-length") ?? ""); + if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) { + void response.body?.cancel().catch(() => {}); + throw protocolError(phase, "response exceeded the maximum size"); + } + const body = response.body; + if (!body) return ""; + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > MAX_RESPONSE_BYTES) { + throw protocolError(phase, "response exceeded the maximum size"); + } + chunks.push(value); + } + } finally { + await reader.cancel().catch(() => {}); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function parseAuthBaseUrl(value: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("Step OAuth authorization endpoint must be a valid URL"); + } + if (url.protocol !== "https:") { + throw new Error("Step CLI login authorization endpoint must use https"); + } + if (url.username || url.password) { + throw new Error("Step OAuth authorization endpoint must not contain credentials"); + } + return url; +} + +function protocolError(phase: "init" | "poll", detail: string, cause?: unknown): StepCliLoginRequestError { + return new StepCliLoginRequestError("protocol", `Step CLI login ${phase} ${detail}`, { + ...(cause === undefined ? {} : { cause }), + }); +} + +function envelopeDetail(payload: unknown): string { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return ""; + const desc = readString(payload as Record, "desc"); + return desc ? `: ${desc.replace(/[\p{Cc}\p{Cf}]/gu, "").slice(0, MAX_ERROR_DETAIL_LENGTH)}` : ""; +} + +function readString(value: Record, key: string): string | undefined { + const result = value[key]; + return typeof result === "string" && result.trim() ? result.trim() : undefined; +} + +function readNumber(value: Record, key: string): number | undefined { + const result = value[key]; + return typeof result === "number" && Number.isFinite(result) ? result : undefined; +} + +/** Bound the account identifier before it reaches local credential storage. */ +function readBoundedUid(value: string | undefined): string | undefined { + if (!value || value.length > 64 || !/^[\w.@:-]+$/u.test(value)) return undefined; + return value; +} + +/** Bound both fetch and body reads, and clean up timers/listeners on every exit. */ +export async function withStepCliLoginTimeout( + timeoutMs: number, + signal: AbortSignal | undefined, + timeoutError: () => Error, + run: (signal: AbortSignal) => Promise, +): Promise { + const timeout = new AbortController(); + const combined = combineAbortSignals([signal, timeout.signal]); + const active = combined.signal!; // timeout.signal is always present + const timer = setTimeout(() => timeout.abort(timeoutError()), Math.max(0, timeoutMs)); + try { + active.throwIfAborted(); + return await raceWithAbortSignal(run(active), active); + } finally { + clearTimeout(timer); + combined.cleanup(); + } +} diff --git a/packages/providers/src/step-provider/login-polling.ts b/packages/providers/src/step-provider/login-polling.ts new file mode 100644 index 00000000..0361383a --- /dev/null +++ b/packages/providers/src/step-provider/login-polling.ts @@ -0,0 +1,52 @@ +import { sleep } from "../utils/sleep.ts"; +import { + isRetryableStepCliLoginError, + pollStepCliLogin, + type StepCliLoginInit, + type StepCliLoginPoll, + withStepCliLoginTimeout, +} from "./login-client.ts"; + +export const MIN_STEP_POLL_INTERVAL_MS = 1_000; +export type StepCliLoginReady = Extract; +export interface WaitForStepCliLoginInput { + readonly init: StepCliLoginInit; + readonly authBaseUrl: string; + readonly pollToken: string; + readonly signal?: AbortSignal; + readonly fetch?: typeof fetch; +} + +// The outer login owns the local timeout; this loop only needs the server expiry. +export async function waitForStepCliLogin(input: WaitForStepCliLoginInput): Promise { + const remaining = input.init.expiresAt * 1_000 - Date.now(); + const timeoutError = () => new Error("Step sign-in timed out before it was approved in the browser."); + input.signal?.throwIfAborted(); + if (remaining <= 0) throw timeoutError(); + const interval = Math.min(60_000, Math.max(MIN_STEP_POLL_INTERVAL_MS, input.init.pollIntervalSec * 1_000)); + return withStepCliLoginTimeout(remaining, input.signal, timeoutError, async (signal) => { + for (;;) { + try { + const result = await pollStepCliLogin({ + authBaseUrl: input.authBaseUrl, + pollToken: input.pollToken, + flowId: input.init.flowId, + signal, + fetch: input.fetch, + }); + signal.throwIfAborted(); + if (result.state === "ready") return result; + if (result.state === "failed") + throw new Error( + result.reason === "denied" + ? "Sign-in was denied in the browser." + : "Step sign-in failed in the browser.", + ); + } catch (error) { + signal.throwIfAborted(); + if (!isRetryableStepCliLoginError(error)) throw error; + } + await sleep(interval, signal); + } + }); +}