Roo-Code/src/services/mcp/utils/callbackServer.ts
2026-03-24 22:22:02 -04:00

195 lines
5.9 KiB
TypeScript

import * as http from "http"
import { t } from "../../../i18n"
export interface CallbackResult {
code?: string
error?: string
error_description?: string
state?: string
}
/**
* Starts a local HTTP server to handle OAuth callback.
* @param port Optional port to use (defaults to random available port)
* @param expectedState Optional expected state for CSRF protection
* @returns Promise<{server: http.Server, port: number, result: Promise<CallbackResult>}>
*/
export function startCallbackServer(
port?: number,
expectedState?: string,
): Promise<{
server: http.Server
port: number
result: Promise<CallbackResult>
}> {
// In test mode, immediately resolve with mock data
if (process.env.MCP_OAUTH_TEST_MODE === "true") {
return new Promise((resolve) => {
const mockServer = http.createServer()
resolve({
server: mockServer,
port: 3000,
result: Promise.resolve({ code: "test-auth-code", state: expectedState }),
})
})
}
return new Promise((resolve, reject) => {
const server = http.createServer()
server.listen(port || 0, "127.0.0.1", () => {
const address = server.address()
if (!address || typeof address === "string") {
reject(new Error("Failed to get server address"))
return
}
const actualPort = address.port
const resultPromise = new Promise<CallbackResult>((resolveResult, rejectResult) => {
let resolved = false
const timeout = setTimeout(
() => {
if (!resolved) {
resolved = true
rejectResult(new Error("Callback timeout"))
server.close()
}
},
5 * 60 * 1000,
) // 5 minutes
server.on("request", (req: any, res: any) => {
if (resolved) return
const url = new URL(req.url || "", `http://localhost:${actualPort}`)
const pathname = url.pathname
if (pathname === "/callback") {
resolved = true
clearTimeout(timeout)
const code = url.searchParams.get("code")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
const state = url.searchParams.get("state")
const hasError = !!error
// Verify state for CSRF protection
if (expectedState && state !== expectedState) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>${t("mcp:oauth.callback.title")}</title>
</head>
<body>
<h1>${t("mcp:oauth.callback.failed")}</h1>
<p>${t("mcp:oauth.callback.invalid_state")}</p>
</body>
</html>
`)
rejectResult(new Error("Invalid state parameter"))
return
}
// Send HTML response
res.writeHead(200, { "Content-Type": "text/html" })
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>${t("mcp:oauth.callback.title")}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 400px; margin: 50px auto; text-align: center; padding: 20px; }
h1 { color: #28a745; }
.error h1 { color: #dc3545; }
.spinner { border: 4px solid #f3f3f3; border-top: 4px solid #28a745; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
button { background: #007acc; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 16px; }
.countdown { font-size: 18px; margin: 10px 0; }
</style>
</head>
<body>
<h1 id="title">${hasError ? t("mcp:oauth.callback.failed") : t("mcp:oauth.callback.success")}</h1>
<div class="spinner" id="spinner" style="${hasError ? "display:none;" : ""}"></div>
<p id="message">
${hasError ? t("mcp:oauth.callback.auth_failed") : t("mcp:oauth.callback.auth_success")}
</p>
<div id="countdown" class="countdown" style="${hasError ? "display:none;" : ""}">${t("mcp:oauth.callback.server_connection_complete")}</div>
<script>
const isError = ${hasError ? "true" : "false"};
if (!isError) {
let count = 5;
const countEl = document.getElementById('count');
const countdownEl = document.getElementById('countdown');
if (countdownEl) {
countdownEl.innerHTML = \`${t("mcp:oauth.callback.tab_closing_in", { count: 5 })}\`;
}
const timer = setInterval(() => {
count--;
const currentCountEl = document.getElementById('count');
if (currentCountEl) currentCountEl.textContent = count;
if (count <= 0) {
clearInterval(timer);
window.close();
if (countdownEl) {
countdownEl.textContent = \`${t("mcp:oauth.callback.safe_to_close")}\`;
}
}
}, 1000);
}
</script>
</body>
</html>
`)
resolveResult({
code: code || undefined,
error: error || undefined,
error_description: errorDescription || undefined,
state: state || undefined,
})
// Close server immediately after response drains
res.on("finish", () => {
server.close()
})
} else {
res.writeHead(404)
res.end("Not found")
}
})
server.on("error", (error: any) => {
if (!resolved) {
resolved = true
clearTimeout(timeout)
rejectResult(error)
}
})
})
resolve({
server,
port: actualPort,
result: resultPromise,
})
})
server.on("error", reject)
})
}
/**
* Stops the callback server.
* @param server The HTTP server to stop
*/
export function stopCallbackServer(server: http.Server): Promise<void> {
return new Promise((resolve) => {
server.close(() => resolve())
})
}