This commit is contained in:
roomote-v0[bot] 2026-04-09 02:56:10 +00:00 committed by GitHub
commit 7804465b6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 294 additions and 3 deletions

View file

@ -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()
})
})
})

View file

@ -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<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}`

View file

@ -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)
})