mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-13 23:11:10 +00:00
Merge 9dd3db81ea into 96d6e43643
This commit is contained in:
commit
a1e6bad319
4 changed files with 437 additions and 14 deletions
183
packages/cloud/src/LocalAuthServer.ts
Normal file
183
packages/cloud/src/LocalAuthServer.ts
Normal 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>`
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
164
packages/cloud/src/__tests__/LocalAuthServer.spec.ts
Normal file
164
packages/cloud/src/__tests__/LocalAuthServer.spec.ts
Normal 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"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue