mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
Merge 467dea3814 into 7adbfec2a4
This commit is contained in:
commit
7804465b6f
3 changed files with 294 additions and 3 deletions
92
apps/cli/src/commands/auth/__tests__/login.test.ts
Normal file
92
apps/cli/src/commands/auth/__tests__/login.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue