Skip to content

Commit 9dd3db8

Browse files
committed
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
1 parent cb83656 commit 9dd3db8

4 files changed

Lines changed: 437 additions & 14 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import http from "http"
2+
import { URL } from "url"
3+
4+
/**
5+
* Result from the local auth server callback.
6+
*/
7+
export interface LocalAuthResult {
8+
code: string
9+
state: string
10+
organizationId: string | null
11+
providerModel: string | null
12+
}
13+
14+
/**
15+
* A temporary local HTTP server that listens for OAuth callbacks.
16+
*
17+
* On Linux desktop environments (e.g., xfce4, some Wayland compositors),
18+
* the `vscode://` custom URI scheme often doesn't work because the desktop
19+
* environment doesn't register it properly. This server provides an alternative
20+
* callback mechanism using `http://127.0.0.1:PORT` which works universally.
21+
*
22+
* The server:
23+
* - Listens on a random available port on 127.0.0.1
24+
* - Waits for a single GET request to /auth/clerk/callback
25+
* - Extracts code, state, organizationId, and provider_model from query params
26+
* - Responds with a success HTML page that the user sees in their browser
27+
* - Resolves the promise with the extracted parameters
28+
* - Automatically shuts down after receiving the callback or timing out
29+
*/
30+
export class LocalAuthServer {
31+
private server: http.Server | null = null
32+
private port: number | null = null
33+
private timeoutHandle: ReturnType<typeof setTimeout> | null = null
34+
35+
/**
36+
* Start the local server and return the port it's listening on.
37+
*
38+
* @returns The port number the server is listening on
39+
*/
40+
async start(): Promise<number> {
41+
return new Promise<number>((resolve, reject) => {
42+
this.server = http.createServer()
43+
44+
this.server.on("error", (err) => {
45+
reject(err)
46+
})
47+
48+
// Listen on a random available port on loopback only
49+
this.server.listen(0, "127.0.0.1", () => {
50+
const address = this.server?.address()
51+
52+
if (address && typeof address === "object") {
53+
this.port = address.port
54+
resolve(this.port)
55+
} else {
56+
reject(new Error("Failed to get server address"))
57+
}
58+
})
59+
})
60+
}
61+
62+
/**
63+
* Wait for the auth callback to arrive.
64+
*
65+
* @param timeoutMs Maximum time to wait for the callback (default: 5 minutes)
66+
* @returns The auth result with code, state, organizationId, and providerModel
67+
*/
68+
waitForCallback(timeoutMs: number = 300_000): Promise<LocalAuthResult> {
69+
return new Promise<LocalAuthResult>((resolve, reject) => {
70+
if (!this.server) {
71+
reject(new Error("Server not started"))
72+
return
73+
}
74+
75+
this.timeoutHandle = setTimeout(() => {
76+
reject(new Error("Authentication timed out waiting for callback"))
77+
this.stop()
78+
}, timeoutMs)
79+
80+
this.server.on("request", (req: http.IncomingMessage, res: http.ServerResponse) => {
81+
// Only handle GET requests to /auth/clerk/callback
82+
const requestUrl = new URL(req.url || "/", `http://127.0.0.1:${this.port}`)
83+
84+
if (req.method !== "GET" || requestUrl.pathname !== "/auth/clerk/callback") {
85+
res.writeHead(404, { "Content-Type": "text/plain" })
86+
res.end("Not Found")
87+
return
88+
}
89+
90+
const code = requestUrl.searchParams.get("code")
91+
const state = requestUrl.searchParams.get("state")
92+
const organizationId = requestUrl.searchParams.get("organizationId")
93+
const providerModel = requestUrl.searchParams.get("provider_model")
94+
95+
// Respond with a success page regardless - the user sees this in their browser
96+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
97+
res.end(this.getSuccessHtml())
98+
99+
if (this.timeoutHandle) {
100+
clearTimeout(this.timeoutHandle)
101+
this.timeoutHandle = null
102+
}
103+
104+
if (!code || !state) {
105+
reject(new Error("Missing code or state in callback"))
106+
} else {
107+
resolve({
108+
code,
109+
state,
110+
organizationId: organizationId === "null" ? null : organizationId,
111+
providerModel: providerModel || null,
112+
})
113+
}
114+
115+
// Shut down after handling the callback
116+
this.stop()
117+
})
118+
})
119+
}
120+
121+
/**
122+
* Get the base URL for the local server (e.g., "http://127.0.0.1:12345").
123+
*/
124+
getRedirectUrl(): string {
125+
if (!this.port) {
126+
throw new Error("Server not started")
127+
}
128+
129+
return `http://127.0.0.1:${this.port}`
130+
}
131+
132+
/**
133+
* Stop the server and clean up resources.
134+
*/
135+
stop(): void {
136+
if (this.timeoutHandle) {
137+
clearTimeout(this.timeoutHandle)
138+
this.timeoutHandle = null
139+
}
140+
141+
if (this.server) {
142+
this.server.close()
143+
this.server = null
144+
}
145+
146+
this.port = null
147+
}
148+
149+
private getSuccessHtml(): string {
150+
return `<!DOCTYPE html>
151+
<html>
152+
<head>
153+
<meta charset="utf-8">
154+
<title>Roo Code - Authentication Successful</title>
155+
<style>
156+
body {
157+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
158+
display: flex;
159+
justify-content: center;
160+
align-items: center;
161+
min-height: 100vh;
162+
margin: 0;
163+
background: #1e1e1e;
164+
color: #cccccc;
165+
}
166+
.container {
167+
text-align: center;
168+
padding: 2rem;
169+
}
170+
h1 { color: #4ec9b0; margin-bottom: 0.5rem; }
171+
p { font-size: 1.1rem; line-height: 1.6; }
172+
</style>
173+
</head>
174+
<body>
175+
<div class="container">
176+
<h1>Authentication Successful</h1>
177+
<p>You can close this tab and return to your editor.</p>
178+
<p>Roo Code is completing your sign-in.</p>
179+
</div>
180+
</body>
181+
</html>`
182+
}
183+
}

packages/cloud/src/WebAuthService.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { getUserAgent } from "./utils.js"
1717
import { importVscode } from "./importVscode.js"
1818
import { InvalidClientTokenError } from "./errors.js"
1919
import { RefreshTimer } from "./RefreshTimer.js"
20+
import { LocalAuthServer } from "./LocalAuthServer.js"
2021

2122
const AUTH_STATE_KEY = "clerk-auth-state"
2223

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

101103
constructor(context: ExtensionContext, log?: (...args: unknown[]) => void) {
102104
super()
@@ -251,6 +253,12 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
251253
* This method initiates the authentication flow by generating a state parameter
252254
* and opening the browser to the authorization URL.
253255
*
256+
* It starts a local HTTP server on 127.0.0.1 as the auth_redirect target.
257+
* This avoids reliance on the vscode:// URI scheme, which doesn't work on
258+
* many Linux desktop environments (e.g., xfce4, some Wayland compositors).
259+
* The vscode:// URI handler is still registered as a parallel mechanism --
260+
* whichever fires first (local server or URI handler) completes the auth.
261+
*
254262
* @param landingPageSlug Optional slug of a specific landing page (e.g., "supernova", "special-offer", etc.)
255263
* @param useProviderSignup If true, uses provider signup flow (/extension/provider-sign-up). If false, uses standard sign-in (/extension/sign-in). Defaults to false.
256264
*/
@@ -265,12 +273,32 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
265273
// Generate a cryptographically random state parameter.
266274
const state = crypto.randomBytes(16).toString("hex")
267275
await this.context.globalState.update(AUTH_STATE_KEY, state)
268-
const packageJSON = this.context.extension?.packageJSON
269-
const publisher = packageJSON?.publisher ?? "RooVeterinaryInc"
270-
const name = packageJSON?.name ?? "roo-cline"
276+
277+
// Start a local HTTP server to receive the OAuth callback.
278+
// This is more reliable than the vscode:// URI scheme on Linux.
279+
this.stopLocalAuthServer()
280+
const localServer = new LocalAuthServer()
281+
this.localAuthServer = localServer
282+
283+
let authRedirect: string
284+
285+
try {
286+
const port = await localServer.start()
287+
authRedirect = localServer.getRedirectUrl()
288+
this.log(`[auth] Local auth server started on port ${port}`)
289+
} catch (serverError) {
290+
// If the local server fails to start, fall back to the vscode:// URI scheme
291+
this.log(`[auth] Failed to start local auth server, falling back to URI scheme: ${serverError}`)
292+
this.localAuthServer = null
293+
const packageJSON = this.context.extension?.packageJSON
294+
const publisher = packageJSON?.publisher ?? "RooVeterinaryInc"
295+
const name = packageJSON?.name ?? "roo-cline"
296+
authRedirect = `${vscode.env.uriScheme}://${publisher}.${name}`
297+
}
298+
271299
const params = new URLSearchParams({
272300
state,
273-
auth_redirect: `${vscode.env.uriScheme}://${publisher}.${name}`,
301+
auth_redirect: authRedirect,
274302
})
275303

276304
// 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
281309
: `${getRooCodeApiUrl()}/extension/sign-in?${params.toString()}`
282310

283311
await vscode.env.openExternal(vscode.Uri.parse(url))
312+
313+
// If we have a local server, start listening for the callback asynchronously.
314+
// The callback will be handled by handleCallback() just like the URI handler path.
315+
if (this.localAuthServer) {
316+
localServer
317+
.waitForCallback()
318+
.then(async (result) => {
319+
this.log("[auth] Received callback via local auth server")
320+
await this.handleCallback(
321+
result.code,
322+
result.state,
323+
result.organizationId,
324+
result.providerModel,
325+
)
326+
})
327+
.catch((err) => {
328+
// Only log if it's not a cancellation (server was stopped because URI handler fired)
329+
if (this.localAuthServer === localServer) {
330+
this.log(`[auth] Local auth server callback error: ${err}`)
331+
}
332+
})
333+
.finally(() => {
334+
if (this.localAuthServer === localServer) {
335+
this.localAuthServer = null
336+
}
337+
})
338+
}
284339
} catch (error) {
340+
this.stopLocalAuthServer()
285341
const context = landingPageSlug ? ` (landing page: ${landingPageSlug})` : ""
286342
this.log(`[auth] Error initiating Roo Code Cloud auth${context}: ${error}`)
287343
throw new Error(`Failed to initiate Roo Code Cloud authentication${context}: ${error}`)
288344
}
289345
}
290346

347+
/**
348+
* Stop the local auth server if it's running.
349+
*
350+
* This is called when the vscode:// URI handler fires first (so we don't
351+
* process the same auth callback twice), or during cleanup.
352+
*/
353+
public stopLocalAuthServer(): void {
354+
if (this.localAuthServer) {
355+
this.log("[auth] Stopping local auth server")
356+
this.localAuthServer.stop()
357+
this.localAuthServer = null
358+
}
359+
}
360+
291361
/**
292362
* Handle the callback from Roo Code Cloud
293363
*
@@ -305,6 +375,10 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
305375
organizationId?: string | null,
306376
providerModel?: string | null,
307377
): Promise<void> {
378+
// Stop the local auth server since we're handling the callback
379+
// (either from URI handler or from the local server itself).
380+
this.stopLocalAuthServer()
381+
308382
if (!code || !state) {
309383
const vscode = await importVscode()
310384

0 commit comments

Comments
 (0)