fix: add localhost HTTP server fallback for OAuth callback on Linux

On Linux desktop environments (e.g., xfce4, some Wayland compositors),
the vscode:// custom URI scheme does not work, preventing the OAuth
callback from reaching the extension after browser authentication.

This adds a LocalAuthServer that starts a temporary HTTP server on
127.0.0.1 with a random port to receive the OAuth callback directly,
bypassing the need for custom URI scheme support.

The server automatically shuts down after receiving the callback or
on timeout (5 minutes). If the local server fails to start, it falls
back to the original vscode:// URI scheme.

Closes #12122
This commit is contained in:
Roo Code 2026-04-15 16:15:38 +00:00
parent cb83656718
commit 9dd3db81ea
4 changed files with 437 additions and 14 deletions

View file

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

View file

@ -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"
@ -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()
@ -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.
*/
@ -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
@ -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
*
@ -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()

View file

@ -0,0 +1,164 @@
import http from "http"
import { LocalAuthServer } from "../LocalAuthServer.js"
describe("LocalAuthServer", () => {
let server: LocalAuthServer
beforeEach(() => {
server = new LocalAuthServer()
})
afterEach(() => {
server.stop()
})
describe("start", () => {
it("should start and listen on a random port", async () => {
const port = await server.start()
expect(port).toBeGreaterThan(0)
expect(port).toBeLessThan(65536)
})
it("should return a valid redirect URL after starting", async () => {
await server.start()
const url = server.getRedirectUrl()
expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/)
})
})
describe("getRedirectUrl", () => {
it("should throw if server is not started", () => {
expect(() => server.getRedirectUrl()).toThrow("Server not started")
})
})
describe("waitForCallback", () => {
it("should resolve with auth result when callback is received", async () => {
const port = await server.start()
const callbackPromise = server.waitForCallback(5000)
// Simulate the browser redirect by making an HTTP request
const response = await makeRequest(
`http://127.0.0.1:${port}/auth/clerk/callback?code=test-code&state=test-state&organizationId=org-123&provider_model=xai/grok`,
)
expect(response.statusCode).toBe(200)
expect(response.body).toContain("Authentication Successful")
const result = await callbackPromise
expect(result).toEqual({
code: "test-code",
state: "test-state",
organizationId: "org-123",
providerModel: "xai/grok",
})
})
it("should handle null organizationId when value is 'null'", async () => {
const port = await server.start()
const callbackPromise = server.waitForCallback(5000)
await makeRequest(
`http://127.0.0.1:${port}/auth/clerk/callback?code=test-code&state=test-state&organizationId=null`,
)
const result = await callbackPromise
expect(result.organizationId).toBeNull()
expect(result.providerModel).toBeNull()
})
it("should handle missing optional parameters", async () => {
const port = await server.start()
const callbackPromise = server.waitForCallback(5000)
await makeRequest(`http://127.0.0.1:${port}/auth/clerk/callback?code=test-code&state=test-state`)
const result = await callbackPromise
expect(result.organizationId).toBeNull()
expect(result.providerModel).toBeNull()
})
it("should reject when code is missing", async () => {
const port = await server.start()
const callbackPromise = server.waitForCallback(5000)
// Make the request and await the rejection concurrently
const [, result] = await Promise.allSettled([
makeRequest(`http://127.0.0.1:${port}/auth/clerk/callback?state=test-state`),
callbackPromise,
])
expect(result.status).toBe("rejected")
expect((result as PromiseRejectedResult).reason.message).toBe("Missing code or state in callback")
})
it("should reject when state is missing", async () => {
const port = await server.start()
const callbackPromise = server.waitForCallback(5000)
// Make the request and await the rejection concurrently
const [, result] = await Promise.allSettled([
makeRequest(`http://127.0.0.1:${port}/auth/clerk/callback?code=test-code`),
callbackPromise,
])
expect(result.status).toBe("rejected")
expect((result as PromiseRejectedResult).reason.message).toBe("Missing code or state in callback")
})
it("should return 404 for non-callback paths", async () => {
const port = await server.start()
server.waitForCallback(5000).catch(() => {}) // Ignore rejection from timeout
const response = await makeRequest(`http://127.0.0.1:${port}/other-path`)
expect(response.statusCode).toBe(404)
})
it("should reject on timeout", async () => {
await server.start()
const callbackPromise = server.waitForCallback(100) // Very short timeout
await expect(callbackPromise).rejects.toThrow("Authentication timed out waiting for callback")
})
it("should reject if server is not started", async () => {
await expect(server.waitForCallback()).rejects.toThrow("Server not started")
})
})
describe("stop", () => {
it("should stop the server cleanly", async () => {
const port = await server.start()
server.stop()
// Trying to connect should fail
await expect(makeRequest(`http://127.0.0.1:${port}/auth/clerk/callback?code=x&state=y`)).rejects.toThrow()
})
it("should be safe to call multiple times", () => {
expect(() => {
server.stop()
server.stop()
}).not.toThrow()
})
})
})
/**
* Helper to make an HTTP GET request and return the response.
*/
function makeRequest(url: string): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
const req = http.get(url, (res) => {
let body = ""
res.on("data", (chunk) => (body += chunk))
res.on("end", () => resolve({ statusCode: res.statusCode || 0, body }))
})
req.on("error", reject)
req.setTimeout(3000, () => {
req.destroy(new Error("Request timed out"))
})
})
}

View file

@ -261,44 +261,46 @@ describe("WebAuthService", () => {
)
})
it("should use package.json values for redirect URI with default sign-in endpoint", async () => {
it("should use localhost auth redirect with default sign-in endpoint", async () => {
const mockOpenExternal = vi.fn()
const vscode = await import("vscode")
vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal)
await authService.login()
const expectedUrl =
"https://api.test.com/extension/sign-in?state=746573742d72616e646f6d2d6279746573&auth_redirect=vscode%3A%2F%2FRooVeterinaryInc.roo-cline"
expect(mockOpenExternal).toHaveBeenCalledWith(
expect.objectContaining({
toString: expect.any(Function),
}),
)
// Verify the actual URL
// Verify the URL uses the local auth server redirect (http://127.0.0.1:PORT)
const calledUri = mockOpenExternal.mock.calls[0]?.[0]
expect(calledUri.toString()).toBe(expectedUrl)
const url = calledUri.toString()
expect(url).toMatch(
/^https:\/\/api\.test\.com\/extension\/sign-in\?state=746573742d72616e646f6d2d6279746573&auth_redirect=http%3A%2F%2F127\.0\.0\.1%3A\d+$/,
)
})
it("should use provider signup URL when useProviderSignup is true", async () => {
it("should use provider signup URL with localhost auth redirect when useProviderSignup is true", async () => {
const mockOpenExternal = vi.fn()
const vscode = await import("vscode")
vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal)
await authService.login(undefined, true)
const expectedUrl =
"https://api.test.com/extension/provider-sign-up?state=746573742d72616e646f6d2d6279746573&auth_redirect=vscode%3A%2F%2FRooVeterinaryInc.roo-cline"
expect(mockOpenExternal).toHaveBeenCalledWith(
expect.objectContaining({
toString: expect.any(Function),
}),
)
// Verify the actual URL
// Verify the URL uses the local auth server redirect (http://127.0.0.1:PORT)
const calledUri = mockOpenExternal.mock.calls[0]?.[0]
expect(calledUri.toString()).toBe(expectedUrl)
const url = calledUri.toString()
expect(url).toMatch(
/^https:\/\/api\.test\.com\/extension\/provider-sign-up\?state=746573742d72616e646f6d2d6279746573&auth_redirect=http%3A%2F%2F127\.0\.0\.1%3A\d+$/,
)
})
it("should handle errors during login", async () => {