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
183 changes: 183 additions & 0 deletions packages/cloud/src/LocalAuthServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import http from "http"
import { URL } from "url"

/**
* Result from the local auth server callback.
*/
export interface LocalAuthResult {
code: string
state: string
organizationId: string | null
providerModel: string | null
}

/**
* A temporary local HTTP server that listens for OAuth callbacks.
*
* On Linux desktop environments (e.g., xfce4, some Wayland compositors),
* the `vscode://` custom URI scheme often doesn't work because the desktop
* environment doesn't register it properly. This server provides an alternative
* callback mechanism using `http://127.0.0.1:PORT` which works universally.
*
* The server:
* - Listens on a random available port on 127.0.0.1
* - Waits for a single GET request to /auth/clerk/callback
* - Extracts code, state, organizationId, and provider_model from query params
* - Responds with a success HTML page that the user sees in their browser
* - Resolves the promise with the extracted parameters
* - Automatically shuts down after receiving the callback or timing out
*/
export class LocalAuthServer {
private server: http.Server | null = null
private port: number | null = null
private timeoutHandle: ReturnType<typeof setTimeout> | null = null

/**
* Start the local server and return the port it's listening on.
*
* @returns The port number the server is listening on
*/
async start(): Promise<number> {
return new Promise<number>((resolve, reject) => {
this.server = http.createServer()

this.server.on("error", (err) => {
reject(err)
})

// Listen on a random available port on loopback only
this.server.listen(0, "127.0.0.1", () => {
const address = this.server?.address()

if (address && typeof address === "object") {
this.port = address.port
resolve(this.port)
} else {
reject(new Error("Failed to get server address"))
}
})
})
}

/**
* Wait for the auth callback to arrive.
*
* @param timeoutMs Maximum time to wait for the callback (default: 5 minutes)
* @returns The auth result with code, state, organizationId, and providerModel
*/
waitForCallback(timeoutMs: number = 300_000): Promise<LocalAuthResult> {
return new Promise<LocalAuthResult>((resolve, reject) => {
if (!this.server) {
reject(new Error("Server not started"))
return
}

this.timeoutHandle = setTimeout(() => {
reject(new Error("Authentication timed out waiting for callback"))
this.stop()
}, timeoutMs)

this.server.on("request", (req: http.IncomingMessage, res: http.ServerResponse) => {
// Only handle GET requests to /auth/clerk/callback
const requestUrl = new URL(req.url || "/", `http://127.0.0.1:${this.port}`)

if (req.method !== "GET" || requestUrl.pathname !== "/auth/clerk/callback") {
res.writeHead(404, { "Content-Type": "text/plain" })
res.end("Not Found")
return
}

const code = requestUrl.searchParams.get("code")
const state = requestUrl.searchParams.get("state")
const organizationId = requestUrl.searchParams.get("organizationId")
const providerModel = requestUrl.searchParams.get("provider_model")

// Respond with a success page regardless - the user sees this in their browser
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(this.getSuccessHtml())

if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}

if (!code || !state) {
reject(new Error("Missing code or state in callback"))
} else {
resolve({
code,
state,
organizationId: organizationId === "null" ? null : organizationId,
providerModel: providerModel || null,
})
}

// Shut down after handling the callback
this.stop()
})
})
}

/**
* Get the base URL for the local server (e.g., "http://127.0.0.1:12345").
*/
getRedirectUrl(): string {
if (!this.port) {
throw new Error("Server not started")
}

return `http://127.0.0.1:${this.port}`
}

/**
* Stop the server and clean up resources.
*/
stop(): void {
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}

if (this.server) {
this.server.close()
this.server = null
}

this.port = null
}

private getSuccessHtml(): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Roo Code - Authentication Successful</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #1e1e1e;
color: #cccccc;
}
.container {
text-align: center;
padding: 2rem;
}
h1 { color: #4ec9b0; margin-bottom: 0.5rem; }
p { font-size: 1.1rem; line-height: 1.6; }
</style>
</head>
<body>
<div class="container">
<h1>Authentication Successful</h1>
<p>You can close this tab and return to your editor.</p>
<p>Roo Code is completing your sign-in.</p>
</div>
</body>
</html>`
}
}
82 changes: 78 additions & 4 deletions packages/cloud/src/WebAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { getUserAgent } from "./utils.js"
import { importVscode } from "./importVscode.js"
import { InvalidClientTokenError } from "./errors.js"
import { RefreshTimer } from "./RefreshTimer.js"
import { LocalAuthServer } from "./LocalAuthServer.js"

const AUTH_STATE_KEY = "clerk-auth-state"

Expand Down Expand Up @@ -97,6 +98,7 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
private sessionToken: string | null = null
private userInfo: CloudUserInfo | null = null
private isFirstRefreshAttempt: boolean = false
private localAuthServer: LocalAuthServer | null = null

constructor(context: ExtensionContext, log?: (...args: unknown[]) => void) {
super()
Expand Down Expand Up @@ -251,6 +253,12 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
* This method initiates the authentication flow by generating a state parameter
* and opening the browser to the authorization URL.
*
* It starts a local HTTP server on 127.0.0.1 as the auth_redirect target.
* This avoids reliance on the vscode:// URI scheme, which doesn't work on
* many Linux desktop environments (e.g., xfce4, some Wayland compositors).
* The vscode:// URI handler is still registered as a parallel mechanism --
* whichever fires first (local server or URI handler) completes the auth.
*
* @param landingPageSlug Optional slug of a specific landing page (e.g., "supernova", "special-offer", etc.)
* @param useProviderSignup If true, uses provider signup flow (/extension/provider-sign-up). If false, uses standard sign-in (/extension/sign-in). Defaults to false.
*/
Expand All @@ -265,12 +273,32 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
// Generate a cryptographically random state parameter.
const state = crypto.randomBytes(16).toString("hex")
await this.context.globalState.update(AUTH_STATE_KEY, state)
const packageJSON = this.context.extension?.packageJSON
const publisher = packageJSON?.publisher ?? "RooVeterinaryInc"
const name = packageJSON?.name ?? "roo-cline"

// Start a local HTTP server to receive the OAuth callback.
// This is more reliable than the vscode:// URI scheme on Linux.
this.stopLocalAuthServer()
const localServer = new LocalAuthServer()
this.localAuthServer = localServer

let authRedirect: string

try {
const port = await localServer.start()
authRedirect = localServer.getRedirectUrl()
this.log(`[auth] Local auth server started on port ${port}`)
} catch (serverError) {
// If the local server fails to start, fall back to the vscode:// URI scheme
this.log(`[auth] Failed to start local auth server, falling back to URI scheme: ${serverError}`)
this.localAuthServer = null
const packageJSON = this.context.extension?.packageJSON
const publisher = packageJSON?.publisher ?? "RooVeterinaryInc"
const name = packageJSON?.name ?? "roo-cline"
authRedirect = `${vscode.env.uriScheme}://${publisher}.${name}`
}

const params = new URLSearchParams({
state,
auth_redirect: `${vscode.env.uriScheme}://${publisher}.${name}`,
auth_redirect: authRedirect,
})

// Use landing page URL if slug is provided, otherwise use provider sign-up or sign-in URL based on parameter
Expand All @@ -281,13 +309,55 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
: `${getRooCodeApiUrl()}/extension/sign-in?${params.toString()}`

await vscode.env.openExternal(vscode.Uri.parse(url))

// If we have a local server, start listening for the callback asynchronously.
// The callback will be handled by handleCallback() just like the URI handler path.
if (this.localAuthServer) {
localServer
.waitForCallback()
.then(async (result) => {
this.log("[auth] Received callback via local auth server")
await this.handleCallback(
result.code,
result.state,
result.organizationId,
result.providerModel,
)
})
.catch((err) => {
// Only log if it's not a cancellation (server was stopped because URI handler fired)
if (this.localAuthServer === localServer) {
this.log(`[auth] Local auth server callback error: ${err}`)
}
})
.finally(() => {
if (this.localAuthServer === localServer) {
this.localAuthServer = null
}
})
}
} catch (error) {
this.stopLocalAuthServer()
const context = landingPageSlug ? ` (landing page: ${landingPageSlug})` : ""
this.log(`[auth] Error initiating Roo Code Cloud auth${context}: ${error}`)
throw new Error(`Failed to initiate Roo Code Cloud authentication${context}: ${error}`)
}
}

/**
* Stop the local auth server if it's running.
*
* This is called when the vscode:// URI handler fires first (so we don't
* process the same auth callback twice), or during cleanup.
*/
public stopLocalAuthServer(): void {
if (this.localAuthServer) {
this.log("[auth] Stopping local auth server")
this.localAuthServer.stop()
this.localAuthServer = null
}
}

/**
* Handle the callback from Roo Code Cloud
*
Expand All @@ -305,6 +375,10 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
organizationId?: string | null,
providerModel?: string | null,
): Promise<void> {
// Stop the local auth server since we're handling the callback
// (either from URI handler or from the local server itself).
this.stopLocalAuthServer()

if (!code || !state) {
const vscode = await importVscode()

Expand Down
Loading
Loading