Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions apps/cli/src/commands/auth/__tests__/login.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
})
200 changes: 199 additions & 1 deletion apps/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -9,6 +10,7 @@ import { saveToken } from "@/lib/storage/index.js"
export interface LoginOptions {
timeout?: number
verbose?: boolean
useDeviceCode?: boolean
}

export type LoginResult =
Expand All @@ -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<LoginResult> {
export async function login({
timeout = 5 * 60 * 1000,
verbose = false,
useDeviceCode = false,
}: LoginOptions = {}): Promise<LoginResult> {
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<LoginOptions, "useDeviceCode"> = {}): Promise<LoginResult> {
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<DeviceCodeResponse>(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<string> {
while (Date.now() < expiresAt) {
await sleep(pollInterval)

if (verbose) {
console.log("[Auth] Polling for authentication result...")
}

try {
const response = await httpPost<DeviceCodePollResponse>(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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

/**
* Simple HTTP POST helper that works with both http and https.
*/
export function httpPost<T>(url: string, body?: Record<string, string>): Promise<T> {
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<LoginOptions, "useDeviceCode"> = {}): Promise<LoginResult> {
const state = randomBytes(16).toString("hex")
const port = await getAvailablePort()
const host = `http://${LOCALHOST}:${port}`
Expand Down
5 changes: 3 additions & 2 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
Loading