This commit is contained in:
roomote-v0[bot] 2026-04-24 11:33:16 +01:00 committed by GitHub
commit a1e6bad319
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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 () => {