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 () => {