From 9dd3db81eac820419d5ae65ec58bea07464d81fd Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 15 Apr 2026 16:15:38 +0000 Subject: [PATCH] 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 --- packages/cloud/src/LocalAuthServer.ts | 183 ++++++++++++++++++ packages/cloud/src/WebAuthService.ts | 82 +++++++- .../src/__tests__/LocalAuthServer.spec.ts | 164 ++++++++++++++++ .../src/__tests__/WebAuthService.spec.ts | 22 ++- 4 files changed, 437 insertions(+), 14 deletions(-) create mode 100644 packages/cloud/src/LocalAuthServer.ts create mode 100644 packages/cloud/src/__tests__/LocalAuthServer.spec.ts diff --git a/packages/cloud/src/LocalAuthServer.ts b/packages/cloud/src/LocalAuthServer.ts new file mode 100644 index 00000000000..451a0a94e22 --- /dev/null +++ b/packages/cloud/src/LocalAuthServer.ts @@ -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 | 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 { + return new Promise((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 { + return new Promise((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 ` + + + + Roo Code - Authentication Successful + + + +
+

Authentication Successful

+

You can close this tab and return to your editor.

+

Roo Code is completing your sign-in.

+
+ +` + } +} diff --git a/packages/cloud/src/WebAuthService.ts b/packages/cloud/src/WebAuthService.ts index 501bf95bb55..67dbbe9fb17 100644 --- a/packages/cloud/src/WebAuthService.ts +++ b/packages/cloud/src/WebAuthService.ts @@ -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 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 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 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 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 implements A organizationId?: string | null, providerModel?: string | null, ): Promise { + // 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() diff --git a/packages/cloud/src/__tests__/LocalAuthServer.spec.ts b/packages/cloud/src/__tests__/LocalAuthServer.spec.ts new file mode 100644 index 00000000000..127e4dad6e4 --- /dev/null +++ b/packages/cloud/src/__tests__/LocalAuthServer.spec.ts @@ -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")) + }) + }) +} diff --git a/packages/cloud/src/__tests__/WebAuthService.spec.ts b/packages/cloud/src/__tests__/WebAuthService.spec.ts index aa406e400d7..4fadc57df79 100644 --- a/packages/cloud/src/__tests__/WebAuthService.spec.ts +++ b/packages/cloud/src/__tests__/WebAuthService.spec.ts @@ -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 () => {