diff --git a/.changeset/cli-device-flow-default.md b/.changeset/cli-device-flow-default.md new file mode 100644 index 000000000..cbe15f782 --- /dev/null +++ b/.changeset/cli-device-flow-default.md @@ -0,0 +1,5 @@ +--- +"ctx7": patch +--- + +`ctx7 login` now always uses the device-code flow. The localhost-callback path is removed — every install (laptop, SSH, Codespace, Docker, CI) goes through the same boxed prompt and verification page. Drops the `--device` flag (it was the opt-in for what's now the default). Older CLI versions (≤ 0.5.0) continue to work against the unchanged auth endpoints, so pinned installs are unaffected. diff --git a/packages/cli/src/__tests__/auth-commands.test.ts b/packages/cli/src/__tests__/auth-commands.test.ts index e124e4a08..9c7e81b34 100644 --- a/packages/cli/src/__tests__/auth-commands.test.ts +++ b/packages/cli/src/__tests__/auth-commands.test.ts @@ -4,12 +4,6 @@ import { Command } from "commander"; const mockGetValidAccessToken = vi.fn(); const mockClearTokens = vi.fn(); const mockSaveTokens = vi.fn(); -const mockGeneratePKCE = vi.fn(); -const mockGenerateState = vi.fn(); -const mockCreateCallbackServer = vi.fn(); -const mockExchangeCodeForTokens = vi.fn(); -const mockBuildAuthorizationUrl = vi.fn(); -const mockShouldUseDeviceFlow = vi.fn((..._args: unknown[]) => false); const mockStartDeviceAuthorization = vi.fn(); const mockPollDeviceToken = vi.fn(); @@ -17,12 +11,6 @@ vi.mock("../utils/auth.js", () => ({ getValidAccessToken: (...args: unknown[]) => mockGetValidAccessToken(...args), clearTokens: (...args: unknown[]) => mockClearTokens(...args), saveTokens: (...args: unknown[]) => mockSaveTokens(...args), - generatePKCE: (...args: unknown[]) => mockGeneratePKCE(...args), - generateState: (...args: unknown[]) => mockGenerateState(...args), - createCallbackServer: (...args: unknown[]) => mockCreateCallbackServer(...args), - exchangeCodeForTokens: (...args: unknown[]) => mockExchangeCodeForTokens(...args), - buildAuthorizationUrl: (...args: unknown[]) => mockBuildAuthorizationUrl(...args), - shouldUseDeviceFlow: (...args: unknown[]) => mockShouldUseDeviceFlow(...args), startDeviceAuthorization: (...args: unknown[]) => mockStartDeviceAuthorization(...args), pollDeviceToken: (...args: unknown[]) => mockPollDeviceToken(...args), DEFAULT_DEVICE_POLL_INTERVAL_SECONDS: 5, @@ -47,7 +35,7 @@ vi.mock("open", () => ({ default: (...args: unknown[]) => mockOpen(...args) })); vi.mock("../constants.js", () => ({ CLI_CLIENT_ID: "test-client-id" })); vi.mock("../utils/api.js", () => ({ getBaseUrl: () => "https://test.context7.com" })); -import { registerAuthCommands, performLogin, performDeviceLogin } from "../commands/auth.js"; +import { registerAuthCommands, performLogin } from "../commands/auth.js"; import { trackEvent } from "../utils/tracking.js"; let logOutput: string[]; @@ -104,15 +92,7 @@ describe("login command", () => { test("calls process.exit(1) when login fails", async () => { mockGetValidAccessToken.mockResolvedValue(null); mockClearTokens.mockReturnValue(false); - // Mock performLogin to fail by making createCallbackServer reject - mockGeneratePKCE.mockReturnValue({ codeVerifier: "v", codeChallenge: "c" }); - mockGenerateState.mockReturnValue("state"); - mockCreateCallbackServer.mockReturnValue({ - port: Promise.resolve(52417), - result: Promise.reject(new Error("timeout")), - close: vi.fn(), - }); - mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth"); + mockStartDeviceAuthorization.mockRejectedValue(new Error("network down")); await runCommand("login").catch(() => {}); expect(process.exit).toHaveBeenCalledWith(1); @@ -190,128 +170,6 @@ describe("whoami command", () => { }); describe("performLogin", () => { - test("returns access_token on success", async () => { - const mockClose = vi.fn(); - mockGeneratePKCE.mockReturnValue({ codeVerifier: "verifier", codeChallenge: "challenge" }); - mockGenerateState.mockReturnValue("state"); - mockCreateCallbackServer.mockReturnValue({ - port: Promise.resolve(52417), - result: Promise.resolve({ code: "auth-code", state: "state" }), - close: mockClose, - }); - mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth"); - mockExchangeCodeForTokens.mockResolvedValue({ - access_token: "new-token", - token_type: "bearer", - }); - - const result = await performLogin(); - expect(result).toBe("new-token"); - expect(mockSaveTokens).toHaveBeenCalled(); - expect(mockClose).toHaveBeenCalled(); - }); - - test("opens browser by default", async () => { - mockGeneratePKCE.mockReturnValue({ codeVerifier: "v", codeChallenge: "c" }); - mockGenerateState.mockReturnValue("s"); - mockCreateCallbackServer.mockReturnValue({ - port: Promise.resolve(52417), - result: Promise.resolve({ code: "code", state: "s" }), - close: vi.fn(), - }); - mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth"); - mockExchangeCodeForTokens.mockResolvedValue({ - access_token: "tok", - token_type: "bearer", - }); - - await performLogin(true); - expect(mockOpen).toHaveBeenCalledWith("https://example.com/auth"); - }); - - test("skips browser open when openBrowser=false", async () => { - mockGeneratePKCE.mockReturnValue({ codeVerifier: "v", codeChallenge: "c" }); - mockGenerateState.mockReturnValue("s"); - mockCreateCallbackServer.mockReturnValue({ - port: Promise.resolve(52417), - result: Promise.resolve({ code: "code", state: "s" }), - close: vi.fn(), - }); - mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth"); - mockExchangeCodeForTokens.mockResolvedValue({ - access_token: "tok", - token_type: "bearer", - }); - - await performLogin(false); - expect(mockOpen).not.toHaveBeenCalled(); - }); - - test("returns null on callback failure", async () => { - const mockClose = vi.fn(); - mockGeneratePKCE.mockReturnValue({ codeVerifier: "v", codeChallenge: "c" }); - mockGenerateState.mockReturnValue("s"); - mockCreateCallbackServer.mockReturnValue({ - port: Promise.resolve(52417), - result: Promise.reject(new Error("User cancelled")), - close: mockClose, - }); - mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth"); - - const result = await performLogin(); - expect(result).toBeNull(); - expect(mockClose).toHaveBeenCalled(); - }); - - test("uses device flow when forceDevice=true", async () => { - mockStartDeviceAuthorization.mockResolvedValue({ - device_code: "dc", - user_code: "ABCD-EFGH", - verification_uri: "https://t/oauth/device", - verification_uri_complete: "https://t/oauth/device?user_code=ABCD-EFGH", - expires_in: 600, - interval: 0, - }); - mockPollDeviceToken.mockResolvedValue({ - status: "approved", - tokens: { access_token: "ctx7sk-x", token_type: "bearer" }, - }); - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ ok: false, json: () => Promise.resolve({}) }) - ); - - const result = await performLogin(false, true); - expect(result).toBe("ctx7sk-x"); - expect(mockCreateCallbackServer).not.toHaveBeenCalled(); - }); - - test("uses device flow when shouldUseDeviceFlow returns true", async () => { - mockShouldUseDeviceFlow.mockReturnValueOnce(true); - mockStartDeviceAuthorization.mockResolvedValue({ - device_code: "dc", - user_code: "X", - verification_uri: "https://t", - verification_uri_complete: undefined, - expires_in: 600, - interval: 0, - }); - mockPollDeviceToken.mockResolvedValue({ - status: "approved", - tokens: { access_token: "tok", token_type: "bearer" }, - }); - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ ok: false, json: () => Promise.resolve({}) }) - ); - - const result = await performLogin(false); - expect(result).toBe("tok"); - expect(mockStartDeviceAuthorization).toHaveBeenCalled(); - }); -}); - -describe("performDeviceLogin", () => { const authorization = { device_code: "dc", user_code: "ABCD-EFGH", @@ -336,7 +194,7 @@ describe("performDeviceLogin", () => { tokens: { access_token: "ctx7sk-x", token_type: "bearer" }, }); - const result = await performDeviceLogin(false); + const result = await performLogin(false); expect(result).toBe("ctx7sk-x"); expect(mockSaveTokens).toHaveBeenCalledWith({ access_token: "ctx7sk-x", @@ -348,7 +206,7 @@ describe("performDeviceLogin", () => { mockStartDeviceAuthorization.mockResolvedValue(authorization); mockPollDeviceToken.mockResolvedValue({ status: "denied" }); - expect(await performDeviceLogin(false)).toBeNull(); + expect(await performLogin(false)).toBeNull(); expect(mockSaveTokens).not.toHaveBeenCalled(); }); @@ -356,7 +214,7 @@ describe("performDeviceLogin", () => { mockStartDeviceAuthorization.mockResolvedValue(authorization); mockPollDeviceToken.mockResolvedValue({ status: "expired" }); - expect(await performDeviceLogin(false)).toBeNull(); + expect(await performLogin(false)).toBeNull(); expect(mockSaveTokens).not.toHaveBeenCalled(); }); @@ -372,7 +230,7 @@ describe("performDeviceLogin", () => { tokens: { access_token: "t", token_type: "bearer" }, }); - const pending = performDeviceLogin(false); + const pending = performLogin(false); // transient bumps the interval by 5s (mirroring slow_down), so the // 2nd and 3rd polls each need a 5s wait. Advance enough to cover both. await vi.advanceTimersByTimeAsync(11_000); @@ -393,7 +251,7 @@ describe("performDeviceLogin", () => { tokens: { access_token: "t", token_type: "bearer" }, }); - const pending = performDeviceLogin(false); + const pending = performLogin(false); // First poll fires after the initial 0ms interval; slow_down then // bumps the interval by 5000ms before the second poll. await vi.advanceTimersByTimeAsync(5500); @@ -408,7 +266,7 @@ describe("performDeviceLogin", () => { test("returns null when start request throws", async () => { mockStartDeviceAuthorization.mockRejectedValue(new Error("network down")); - expect(await performDeviceLogin(false)).toBeNull(); + expect(await performLogin(false)).toBeNull(); expect(mockPollDeviceToken).not.toHaveBeenCalled(); }); @@ -422,7 +280,7 @@ describe("performDeviceLogin", () => { tokens: { access_token: "t", token_type: "bearer" }, }); - await performDeviceLogin(true); + await performLogin(true); expect(mockOpen).toHaveBeenCalledWith(authorization.verification_uri_complete); } finally { Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); @@ -436,7 +294,7 @@ describe("performDeviceLogin", () => { tokens: { access_token: "t", token_type: "bearer" }, }); - await performDeviceLogin(false); + await performLogin(false); expect(mockOpen).not.toHaveBeenCalled(); }); @@ -449,7 +307,7 @@ describe("performDeviceLogin", () => { tokens: { access_token: "t", token_type: "bearer" }, }); - const pending = performDeviceLogin(false); + const pending = performLogin(false); // Two 5s polls. await vi.advanceTimersByTimeAsync(11_000); const result = await pending; diff --git a/packages/cli/src/__tests__/auth-utils.test.ts b/packages/cli/src/__tests__/auth-utils.test.ts index 5681ea359..6b7080c1e 100644 --- a/packages/cli/src/__tests__/auth-utils.test.ts +++ b/packages/cli/src/__tests__/auth-utils.test.ts @@ -1,5 +1,4 @@ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; -import * as crypto from "crypto"; vi.mock("os", () => ({ homedir: () => "/fake-home", default: { homedir: () => "/fake-home" } })); @@ -19,17 +18,11 @@ vi.mock("../utils/api.js", () => ({ getBaseUrl: () => "https://test.context7.com import * as fs from "fs"; import { - generatePKCE, - generateState, saveTokens, loadTokens, clearTokens, isTokenExpired, getValidAccessToken, - exchangeCodeForTokens, - buildAuthorizationUrl, - createCallbackServer, - shouldUseDeviceFlow, startDeviceAuthorization, pollDeviceToken, type TokenData, @@ -53,31 +46,6 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe("generatePKCE", () => { - test("returns codeVerifier and codeChallenge with correct SHA-256 relationship", () => { - const { codeVerifier, codeChallenge } = generatePKCE(); - const expected = crypto.createHash("sha256").update(codeVerifier).digest("base64url"); - expect(codeChallenge).toBe(expected); - }); - - test("generates unique values on each call", () => { - const a = generatePKCE(); - const b = generatePKCE(); - expect(a.codeVerifier).not.toBe(b.codeVerifier); - }); -}); - -describe("generateState", () => { - test("returns a base64url string", () => { - const state = generateState(); - expect(state).toMatch(/^[A-Za-z0-9_-]+$/); - }); - - test("generates unique values on each call", () => { - expect(generateState()).not.toBe(generateState()); - }); -}); - describe("saveTokens", () => { test("creates config directory if it does not exist", () => { mfs.existsSync.mockReturnValue(false); @@ -273,204 +241,6 @@ describe("getValidAccessToken", () => { }); }); -describe("exchangeCodeForTokens", () => { - test("POSTs correct parameters and returns TokenData on success", async () => { - const tokenResponse: TokenData = { access_token: "new-tok", token_type: "bearer" }; - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(tokenResponse), - }) - ); - - const result = await exchangeCodeForTokens( - "https://example.com", - "auth-code", - "verifier", - "http://localhost:52417/callback", - "client-id" - ); - - expect(result).toEqual(tokenResponse); - expect(fetch).toHaveBeenCalledWith( - "https://example.com/api/oauth/token", - expect.objectContaining({ - method: "POST", - body: expect.stringContaining("grant_type=authorization_code"), - }) - ); - const body = vi.mocked(fetch).mock.calls[0][1]!.body as string; - const params = new URLSearchParams(body); - expect(params.get("client_id")).toBe("client-id"); - expect(params.get("code")).toBe("auth-code"); - expect(params.get("code_verifier")).toBe("verifier"); - expect(params.get("redirect_uri")).toBe("http://localhost:52417/callback"); - }); - - test("throws with error_description on failure", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: false, - json: () => Promise.resolve({ error_description: "bad code" }), - }) - ); - - await expect( - exchangeCodeForTokens("https://example.com", "code", "verifier", "redirect", "client") - ).rejects.toThrow("bad code"); - }); - - test("throws generic message when response has no JSON", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: false, - json: () => Promise.reject(new Error("no json")), - }) - ); - - await expect( - exchangeCodeForTokens("https://example.com", "code", "verifier", "redirect", "client") - ).rejects.toThrow("Failed to exchange code for tokens"); - }); -}); - -describe("buildAuthorizationUrl", () => { - test("constructs URL with all required parameters", () => { - const result = buildAuthorizationUrl( - "https://example.com", - "client-id", - "http://localhost:52417/callback", - "challenge", - "state-value" - ); - - const url = new URL(result); - expect(url.origin).toBe("https://example.com"); - expect(url.pathname).toBe("/api/oauth/authorize"); - expect(url.searchParams.get("client_id")).toBe("client-id"); - expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:52417/callback"); - expect(url.searchParams.get("code_challenge")).toBe("challenge"); - expect(url.searchParams.get("code_challenge_method")).toBe("S256"); - expect(url.searchParams.get("state")).toBe("state-value"); - expect(url.searchParams.get("scope")).toBe("profile email"); - expect(url.searchParams.get("response_type")).toBe("code"); - }); -}); - -describe("createCallbackServer", () => { - async function httpGet(url: string): Promise { - const http = await import("http"); - return new Promise((resolve, reject) => { - http - .get(url, (res) => { - res.resume(); - res.on("end", resolve); - }) - .on("error", reject); - }); - } - - function closeAndWait(closeFn: () => void): Promise { - return new Promise((resolve) => { - closeFn(); - setTimeout(resolve, 100); - }); - } - - test("resolves with code and state on valid callback", async () => { - const server = createCallbackServer("expected-state"); - const port = await server.port; - await httpGet(`http://127.0.0.1:${port}/callback?code=auth-code&state=expected-state`); - const result = await server.result; - expect(result).toEqual({ code: "auth-code", state: "expected-state" }); - await closeAndWait(server.close); - }); - - test("rejects on state mismatch", async () => { - const server = createCallbackServer("expected-state"); - const resultPromise = server.result.catch((e: Error) => e); - const port = await server.port; - await httpGet(`http://127.0.0.1:${port}/callback?code=auth-code&state=wrong-state`); - const err = await resultPromise; - expect(err).toBeInstanceOf(Error); - expect((err as Error).message).toBe("State mismatch"); - await closeAndWait(server.close); - }); - - test("rejects on missing code", async () => { - const server = createCallbackServer("expected-state"); - const resultPromise = server.result.catch((e: Error) => e); - const port = await server.port; - await httpGet(`http://127.0.0.1:${port}/callback?state=expected-state`); - const err = await resultPromise; - expect(err).toBeInstanceOf(Error); - expect((err as Error).message).toBe("Missing authorization code or state"); - await closeAndWait(server.close); - }); - - test("rejects on error parameter", async () => { - const server = createCallbackServer("expected-state"); - const resultPromise = server.result.catch((e: Error) => e); - const port = await server.port; - await httpGet( - `http://127.0.0.1:${port}/callback?error=access_denied&error_description=User+cancelled` - ); - const err = await resultPromise; - expect(err).toBeInstanceOf(Error); - expect((err as Error).message).toBe("User cancelled"); - await closeAndWait(server.close); - }); -}); - -describe("shouldUseDeviceFlow", () => { - const originalEnv = { ...process.env }; - const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; - - beforeEach(() => { - delete process.env.SSH_CONNECTION; - delete process.env.SSH_CLIENT; - delete process.env.SSH_TTY; - delete process.env.DISPLAY; - delete process.env.WAYLAND_DISPLAY; - }); - - afterEach(() => { - process.env = { ...originalEnv }; - Object.defineProperty(process, "platform", originalPlatform); - }); - - test("false on macOS with no SSH env", () => { - Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); - expect(shouldUseDeviceFlow()).toBe(false); - }); - - test.each(["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"])("true when %s is set", (key) => { - Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); - process.env[key] = "anything"; - expect(shouldUseDeviceFlow()).toBe(true); - }); - - test("true on Linux without DISPLAY / WAYLAND_DISPLAY", () => { - Object.defineProperty(process, "platform", { value: "linux", configurable: true }); - expect(shouldUseDeviceFlow()).toBe(true); - }); - - test("false on Linux with DISPLAY set", () => { - Object.defineProperty(process, "platform", { value: "linux", configurable: true }); - process.env.DISPLAY = ":0"; - expect(shouldUseDeviceFlow()).toBe(false); - }); - - test("false on Linux with WAYLAND_DISPLAY set", () => { - Object.defineProperty(process, "platform", { value: "linux", configurable: true }); - process.env.WAYLAND_DISPLAY = "wayland-0"; - expect(shouldUseDeviceFlow()).toBe(false); - }); -}); - describe("startDeviceAuthorization", () => { test("returns parsed response on 200", async () => { const payload = { diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 951ab5a28..df8931095 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -4,15 +4,9 @@ import ora from "ora"; import open from "open"; import boxen from "boxen"; import { - generatePKCE, - generateState, - createCallbackServer, - exchangeCodeForTokens, saveTokens, clearTokens, - buildAuthorizationUrl, getValidAccessToken, - shouldUseDeviceFlow, startDeviceAuthorization, pollDeviceToken, DEFAULT_DEVICE_POLL_INTERVAL_SECONDS, @@ -33,7 +27,6 @@ export function registerAuthCommands(program: Command): void { .command("login") .description("Log in to Context7") .option("--no-browser", "Don't open browser automatically") - .option("--device", "Force device-code flow (use on SSH / headless hosts)") .action(async (options) => { await loginCommand(options); }); @@ -115,7 +108,7 @@ async function announceIdentity(accessToken: string): Promise { } } -export async function performDeviceLogin(openBrowser = true): Promise { +export async function performLogin(openBrowser = true): Promise { const spinner = ora("Preparing login...").start(); let authorization; @@ -196,81 +189,7 @@ export async function performDeviceLogin(openBrowser = true): Promise { - if (forceDevice || shouldUseDeviceFlow()) { - return performDeviceLogin(openBrowser); - } - - const spinner = ora("Preparing login...").start(); - - try { - const { codeVerifier, codeChallenge } = generatePKCE(); - const state = generateState(); - const callbackServer = createCallbackServer(state); - const port = await callbackServer.port; - const redirectUri = `http://localhost:${port}/callback`; - const authUrl = buildAuthorizationUrl( - baseUrl, - CLI_CLIENT_ID, - redirectUri, - codeChallenge, - state - ); - - spinner.stop(); - - console.log(""); - console.log(pc.bold("Opening browser to log in...")); - console.log(""); - - if (openBrowser) { - await open(authUrl); - console.log(pc.dim("If the browser didn't open, visit this URL:")); - } else { - console.log(pc.dim("Open this URL in your browser:")); - } - console.log(pc.cyan(authUrl)); - console.log(""); - - const waitingSpinner = ora("Waiting for login...").start(); - - try { - const { code } = await callbackServer.result; - waitingSpinner.text = "Exchanging code for tokens..."; - - const tokens = await exchangeCodeForTokens( - baseUrl, - code, - codeVerifier, - redirectUri, - CLI_CLIENT_ID - ); - saveTokens(tokens); - callbackServer.close(); - - waitingSpinner.succeed(pc.green("Login successful!")); - return tokens.access_token; - } catch (error) { - callbackServer.close(); - waitingSpinner.fail(pc.red("Login failed")); - if (error instanceof Error) { - console.error(pc.red(error.message)); - } - return null; - } - } catch (error) { - spinner.fail(pc.red("Login failed")); - if (error instanceof Error) { - console.error(pc.red(error.message)); - } - return null; - } -} - -async function loginCommand(options: { browser: boolean; device?: boolean }): Promise { +async function loginCommand(options: { browser: boolean }): Promise { trackEvent("command", { name: "login" }); const existingToken = await getValidAccessToken(); if (existingToken) { @@ -280,7 +199,7 @@ async function loginCommand(options: { browser: boolean; device?: boolean }): Pr } clearTokens(); - const token = await performLogin(options.browser, options.device ?? false); + const token = await performLogin(options.browser); if (!token) { process.exit(1); } diff --git a/packages/cli/src/utils/auth.ts b/packages/cli/src/utils/auth.ts index 02abf1f5c..46a6d9306 100644 --- a/packages/cli/src/utils/auth.ts +++ b/packages/cli/src/utils/auth.ts @@ -1,5 +1,3 @@ -import * as crypto from "crypto"; -import * as http from "http"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; @@ -18,21 +16,6 @@ export interface TokenData { scope?: string; } -export interface PKCEChallenge { - codeVerifier: string; - codeChallenge: string; -} - -export function generatePKCE(): PKCEChallenge { - const codeVerifier = crypto.randomBytes(32).toString("base64url"); - const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url"); - return { codeVerifier, codeChallenge }; -} - -export function generateState(): string { - return crypto.randomBytes(16).toString("base64url"); -} - function ensureConfigDir(): void { if (!fs.existsSync(CONFIG_DIR)) { fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); @@ -96,8 +79,10 @@ async function refreshAccessToken(refreshToken: string): Promise { } /** - * Returns a valid access token, refreshing if expired. - * Returns null if no tokens exist or refresh fails. + * Returns a valid access token, refreshing if expired. Returns null if no + * tokens are stored or refresh fails. Pre-0.5 installs may have OAuth tokens + * with a `refresh_token`; new installs hold long-lived API keys that never + * expire and skip the refresh path entirely. */ export async function getValidAccessToken(): Promise { const tokens = loadTokens(); @@ -120,199 +105,11 @@ export async function getValidAccessToken(): Promise { } } -export interface CallbackResult { - code: string; - state: string; -} - -// Port for OAuth callback server - must match registered redirect URI -const CALLBACK_PORT = 52417; - -export function createCallbackServer(expectedState: string): { - port: Promise; - result: Promise; - close: () => void; -} { - let resolvePort: (port: number) => void; - let resolveResult: (result: CallbackResult) => void; - let rejectResult: (error: Error) => void; - let serverInstance: http.Server | null = null; - - const portPromise = new Promise((resolve) => { - resolvePort = resolve; - }); - - const resultPromise = new Promise((resolve, reject) => { - resolveResult = resolve; - rejectResult = reject; - }); - - const server = http.createServer((req, res) => { - const url = new URL(req.url || "/", `http://localhost`); - - if (url.pathname === "/callback") { - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const error = url.searchParams.get("error"); - const errorDescription = url.searchParams.get("error_description"); - - res.writeHead(200, { "Content-Type": "text/html" }); - - if (error) { - res.end(errorPage(errorDescription || error)); - serverInstance?.close(); - rejectResult(new Error(errorDescription || error)); - return; - } - - if (!code || !state) { - res.end(errorPage("Missing authorization code or state")); - serverInstance?.close(); - rejectResult(new Error("Missing authorization code or state")); - return; - } - - if (state !== expectedState) { - res.end(errorPage("State mismatch - possible CSRF attack")); - serverInstance?.close(); - rejectResult(new Error("State mismatch")); - return; - } - - res.end(successPage()); - serverInstance?.close(); - resolveResult({ code, state }); - } else { - res.writeHead(404); - res.end("Not found"); - } - }); - - serverInstance = server; - - server.on("error", (err) => { - rejectResult(err as Error); - }); - - server.listen(CALLBACK_PORT, "127.0.0.1", () => { - resolvePort(CALLBACK_PORT); - }); - - const timeout = setTimeout( - () => { - server.close(); - rejectResult(new Error("Login timed out after 5 minutes")); - }, - 5 * 60 * 1000 - ); - - return { - port: portPromise, - result: resultPromise, - close: () => { - clearTimeout(timeout); - server.close(); - }, - }; -} - -function successPage(): string { - return ` - - Login Successful - -
-
- - - -
-

Login Successful!

-

You can close this window and return to the terminal.

-
- -`; -} - -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function errorPage(message: string): string { - const safeMessage = escapeHtml(message); - return ` - - Login Failed - -
-
- - - -
-

Login Failed

-

${safeMessage}

-

You can close this window.

-
- -`; -} - interface TokenErrorResponse { error?: string; error_description?: string; } -export async function exchangeCodeForTokens( - baseUrl: string, - code: string, - codeVerifier: string, - redirectUri: string, - clientId: string -): Promise { - const response = await fetch(`${baseUrl}/api/oauth/token`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: clientId, - code, - code_verifier: codeVerifier, - redirect_uri: redirectUri, - }).toString(), - }); - - if (!response.ok) { - const err = (await response.json().catch(() => ({}))) as TokenErrorResponse; - throw new Error(err.error_description || err.error || "Failed to exchange code for tokens"); - } - - return (await response.json()) as TokenData; -} - -export function buildAuthorizationUrl( - baseUrl: string, - clientId: string, - redirectUri: string, - codeChallenge: string, - state: string -): string { - const url = new URL(`${baseUrl}/api/oauth/authorize`); - url.searchParams.set("client_id", clientId); - url.searchParams.set("redirect_uri", redirectUri); - url.searchParams.set("code_challenge", codeChallenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", state); - url.searchParams.set("scope", "profile email"); - url.searchParams.set("response_type", "code"); - return url.toString(); -} - export interface DeviceAuthorizationResponse { device_code: string; user_code: string; @@ -328,22 +125,13 @@ const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; /** RFC 8628 §3.2 default poll interval when the server omits `interval`. */ export const DEFAULT_DEVICE_POLL_INTERVAL_SECONDS = 5; -/** Heuristic: prefer device flow on SSH and headless Linux. False positives are fine — device flow works locally too. */ -export function shouldUseDeviceFlow(): boolean { - if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) return true; - if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { - return true; - } - return false; -} - export async function startDeviceAuthorization( baseUrl: string, clientId: string ): Promise { // Hostname is shown on the server's verification page so the user can confirm // that the device they're authorizing matches the one running the CLI - // (RFC 8628 §5.4 phishing resistance). Best-effort; falls back to "unknown". + // (RFC 8628 §5.4 phishing resistance). Best-effort. const params = new URLSearchParams({ client_id: clientId }); try { const hostname = os.hostname();