Roo-Code/apps/cli/src/commands/auth/login.ts

177 lines
4.6 KiB
TypeScript

import http from "http"
import { randomBytes } from "crypto"
import net from "net"
import { exec } from "child_process"
import { AUTH_BASE_URL } from "@/types/index.js"
import { saveToken } from "@/lib/storage/index.js"
export interface LoginOptions {
timeout?: number
verbose?: boolean
}
export type LoginResult =
| {
success: true
token: string
}
| {
success: false
error: string
}
const LOCALHOST = "127.0.0.1"
export async function login({ timeout = 5 * 60 * 1000, verbose = false }: LoginOptions = {}): Promise<LoginResult> {
const state = randomBytes(16).toString("hex")
const port = await getAvailablePort()
const host = `http://${LOCALHOST}:${port}`
if (verbose) {
console.log(`[Auth] Starting local callback server on port ${port}`)
}
// Create promise that will be resolved when we receive the callback.
const tokenPromise = new Promise<{ token: string; state: string }>((resolve, reject) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url!, host)
if (url.pathname === "/callback") {
const receivedState = url.searchParams.get("state")
const token = url.searchParams.get("token")
const error = url.searchParams.get("error")
if (error) {
const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=error-in-callback`)
errorUrl.searchParams.set("message", error)
res.writeHead(302, { Location: errorUrl.toString() })
res.end(() => {
server.close()
reject(new Error(error))
})
} else if (!token) {
const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=missing-token`)
errorUrl.searchParams.set("message", "Missing token in callback")
res.writeHead(302, { Location: errorUrl.toString() })
res.end(() => {
server.close()
reject(new Error("Missing token in callback"))
})
} else if (receivedState !== state) {
const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=invalid-state-parameter`)
errorUrl.searchParams.set("message", "Invalid state parameter")
res.writeHead(302, { Location: errorUrl.toString() })
res.end(() => {
server.close()
reject(new Error("Invalid state parameter"))
})
} else {
res.writeHead(302, { Location: `${AUTH_BASE_URL}/cli/sign-in?success=true` })
res.end(() => {
server.close()
resolve({ token, state: receivedState })
})
}
} else {
res.writeHead(404, { "Content-Type": "text/plain" })
res.end("Not found")
}
})
server.listen(port, LOCALHOST)
const timeoutId = setTimeout(() => {
server.close()
reject(new Error("Authentication timed out"))
}, timeout)
server.on("close", () => {
clearTimeout(timeoutId)
})
})
const authUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in`)
authUrl.searchParams.set("state", state)
authUrl.searchParams.set("callback", `${host}/callback`)
console.log("Opening browser for authentication...")
console.log(`If the browser doesn't open, visit: ${authUrl.toString()}`)
try {
await openBrowser(authUrl.toString())
} catch (error) {
if (verbose) {
console.warn("[Auth] Failed to open browser automatically:", error)
}
console.log("Please open the URL above in your browser manually.")
}
try {
const { token } = await tokenPromise
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 }
}
}
async function getAvailablePort(startPort = 49152, endPort = 65535): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer()
let port = startPort
const tryPort = () => {
server.once("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE" && port < endPort) {
port++
tryPort()
} else {
reject(err)
}
})
server.once("listening", () => {
server.close(() => {
resolve(port)
})
})
server.listen(port, LOCALHOST)
}
tryPort()
})
}
function openBrowser(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const platform = process.platform
let command: string
switch (platform) {
case "darwin":
command = `open "${url}"`
break
case "win32":
command = `start "" "${url}"`
break
default:
// Linux and other Unix-like systems.
command = `xdg-open "${url}"`
break
}
exec(command, (error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}