diff --git a/apps/cli/src/commands/auth/__tests__/login.test.ts b/apps/cli/src/commands/auth/__tests__/login.test.ts new file mode 100644 index 00000000000..e33c46a0c43 --- /dev/null +++ b/apps/cli/src/commands/auth/__tests__/login.test.ts @@ -0,0 +1,92 @@ +import { pollForToken, httpPost, deviceCodeLogin, login } from "../login.js" + +// Mock saveToken +vi.mock("@/lib/storage/index.js", () => ({ + saveToken: vi.fn().mockResolvedValue(undefined), +})) + +describe("login", () => { + describe("login() routing", () => { + it("should use device code flow when useDeviceCode is true", async () => { + // We can't easily test the full flow without mocking httpPost, + // but we can verify the function signature accepts the option. + const result = await login({ useDeviceCode: true, timeout: 100, verbose: false }) + // It will fail because there's no server, but it should attempt device code flow + expect(result.success).toBe(false) + }) + + it("should default useDeviceCode to false", async () => { + // Verify the default options shape works without errors + // We don't test the full browser callback flow here since it requires + // a real HTTP server and browser interaction (existing behavior). + const options = { timeout: 100, verbose: false } + expect(options).toBeDefined() + }) + }) + + describe("pollForToken", () => { + it("should throw on timeout when expiresAt is in the past", async () => { + const pollPromise = pollForToken({ + pollUrl: "http://localhost:3000/api/cli/device-code/poll", + deviceCode: "test-device-code", + pollInterval: 100, + expiresAt: Date.now() - 1000, // Already expired + verbose: false, + }) + + await expect(pollPromise).rejects.toThrow("Authentication timed out") + }) + + it("should timeout when server never returns complete", async () => { + // Use a very short expiration to test the timeout path quickly + const pollPromise = pollForToken({ + pollUrl: "http://127.0.0.1:1/api/cli/device-code/poll", + deviceCode: "test-device-code", + pollInterval: 50, + expiresAt: Date.now() + 200, + verbose: false, + }) + + await expect(pollPromise).rejects.toThrow("Authentication timed out") + }, 10_000) + }) + + describe("httpPost", () => { + it("should reject on invalid URL", async () => { + await expect(httpPost("not-a-valid-url")).rejects.toThrow() + }) + + it("should reject when server is unreachable", async () => { + // Use a port that's almost certainly not listening + await expect(httpPost("http://127.0.0.1:1/test")).rejects.toThrow() + }) + }) + + describe("deviceCodeLogin", () => { + it("should return failure when server is unreachable", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const result = await deviceCodeLogin({ timeout: 1000, verbose: false }) + + expect(result.success).toBe(false) + + consoleSpy.mockRestore() + }) + + it("should pass verbose flag through", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const result = await deviceCodeLogin({ timeout: 1000, verbose: true }) + + expect(result.success).toBe(false) + // Verify verbose output was attempted + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("[Auth] Starting device code authentication flow"), + ) + + consoleSpy.mockRestore() + consoleErrorSpy.mockRestore() + }) + }) +}) diff --git a/apps/cli/src/commands/auth/login.ts b/apps/cli/src/commands/auth/login.ts index ab85385b0f9..6df262716a3 100644 --- a/apps/cli/src/commands/auth/login.ts +++ b/apps/cli/src/commands/auth/login.ts @@ -1,4 +1,5 @@ import http from "http" +import https from "https" import { randomBytes } from "crypto" import net from "net" import { exec } from "child_process" @@ -9,6 +10,7 @@ import { saveToken } from "@/lib/storage/index.js" export interface LoginOptions { timeout?: number verbose?: boolean + useDeviceCode?: boolean } export type LoginResult = @@ -21,9 +23,205 @@ export type LoginResult = error: string } +export interface DeviceCodeResponse { + device_code: string + user_code: string + verification_uri: string + expires_in: number + interval: number +} + +export interface DeviceCodePollResponse { + status: "pending" | "complete" | "expired" + token?: string +} + const LOCALHOST = "127.0.0.1" -export async function login({ timeout = 5 * 60 * 1000, verbose = false }: LoginOptions = {}): Promise { +export async function login({ + timeout = 5 * 60 * 1000, + verbose = false, + useDeviceCode = false, +}: LoginOptions = {}): Promise { + if (useDeviceCode) { + return deviceCodeLogin({ timeout, verbose }) + } + return browserCallbackLogin({ timeout, verbose }) +} + +/** + * Device code authentication flow, similar to GitHub CLI's device code flow. + * This works on remote/headless servers where a local browser callback is not feasible. + * + * Flow: + * 1. Request a device code from the auth server + * 2. Display the verification URL and user code to the user + * 3. User opens the URL on any device and enters the code + * 4. CLI polls the server until authentication is complete + */ +export async function deviceCodeLogin({ + timeout = 5 * 60 * 1000, + verbose = false, +}: Omit = {}): Promise { + if (verbose) { + console.log("[Auth] Starting device code authentication flow") + } + + try { + // Step 1: Request a device code from the auth server. + const deviceCodeUrl = `${AUTH_BASE_URL}/api/cli/device-code` + + if (verbose) { + console.log(`[Auth] Requesting device code from ${deviceCodeUrl}`) + } + + const deviceCodeResponse = await httpPost(deviceCodeUrl) + + const { device_code, user_code, verification_uri, expires_in, interval } = deviceCodeResponse + + // Step 2: Display instructions to the user. + console.log("") + console.log("To authenticate, open the following URL in a browser on any device:") + console.log("") + console.log(` ${verification_uri}`) + console.log("") + console.log(`Then enter this code: ${user_code}`) + console.log("") + console.log("Waiting for authentication...") + + // Step 3: Poll for completion. + const pollUrl = `${AUTH_BASE_URL}/api/cli/device-code/poll` + const pollInterval = (interval || 5) * 1000 + const expiresAt = Date.now() + Math.min(expires_in * 1000, timeout) + + const token = await pollForToken({ + pollUrl, + deviceCode: device_code, + pollInterval, + expiresAt, + verbose, + }) + + // Step 4: Save and return. + await saveToken(token) + console.log("✓ Successfully authenticated!") + return { success: true, token } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`✗ Authentication failed: ${message}`) + return { success: false, error: message } + } +} + +interface PollOptions { + pollUrl: string + deviceCode: string + pollInterval: number + expiresAt: number + verbose: boolean +} + +export async function pollForToken({ + pollUrl, + deviceCode, + pollInterval, + expiresAt, + verbose, +}: PollOptions): Promise { + while (Date.now() < expiresAt) { + await sleep(pollInterval) + + if (verbose) { + console.log("[Auth] Polling for authentication result...") + } + + try { + const response = await httpPost(pollUrl, { device_code: deviceCode }) + + if (response.status === "complete" && response.token) { + return response.token + } + + if (response.status === "expired") { + throw new Error("Device code expired. Please try again.") + } + + // status === "pending", continue polling + } catch (error) { + // If it's a known error (expired, etc.), rethrow + if (error instanceof Error && error.message.includes("expired")) { + throw error + } + + if (verbose) { + console.warn("[Auth] Poll request failed, retrying:", error) + } + } + } + + throw new Error("Authentication timed out. Please try again.") +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * Simple HTTP POST helper that works with both http and https. + */ +export function httpPost(url: string, body?: Record): Promise { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url) + const isHttps = parsedUrl.protocol === "https:" + const transport = isHttps ? https : http + + const postData = body ? JSON.stringify(body) : "" + + const options = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (isHttps ? 443 : 80), + path: parsedUrl.pathname + parsedUrl.search, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(postData), + }, + } + + const req = transport.request(options, (res) => { + let data = "" + + res.on("data", (chunk: Buffer | string) => { + data += chunk + }) + + res.on("end", () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(JSON.parse(data) as T) + } catch { + reject(new Error(`Invalid JSON response: ${data}`)) + } + } else { + reject(new Error(`HTTP ${res.statusCode}: ${data}`)) + } + }) + }) + + req.on("error", reject) + + if (postData) { + req.write(postData) + } + + req.end() + }) +} + +async function browserCallbackLogin({ + timeout = 5 * 60 * 1000, + verbose = false, +}: Omit = {}): Promise { const state = randomBytes(16).toString("hex") const port = await getAvailablePort() const host = `http://${LOCALHOST}:${port}` diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 2805e6c9099..2355bb2a39d 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -142,8 +142,9 @@ authCommand .command("login") .description("Authenticate with Roo Code Cloud") .option("-v, --verbose", "Enable verbose output", false) - .action(async (options: { verbose: boolean }) => { - const result = await login({ verbose: options.verbose }) + .option("--device-code", "Use device code flow for authentication (useful for remote/headless servers)", false) + .action(async (options: { verbose: boolean; deviceCode: boolean }) => { + const result = await login({ verbose: options.verbose, useDeviceCode: options.deviceCode }) process.exit(result.success ? 0 : 1) })