UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

314 lines • 15.5 kB
// oauth-flow.ts — generic OAuth engine for in-panel provider sign-in. // Two primitives (loopback-PKCE, device-code) driven by a per-provider config. // SECURITY: PKCE S256 + state on every loopback exchange; loopback bound to // 127.0.0.1 only and torn down after use; bearer only sent to allowlisted HTTPS // hosts; token material never logged. See docs/superpowers/specs/2026-07-11-oauth-in-panel-design.md. import { createHash, randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { homedir } from "node:os"; import { join } from "node:path"; import { ValidationError } from "../utils/errors.js"; import { logger } from "../utils/logger.js"; const b64url = (buf) => buf.toString("base64url"); /** * Strip token-shaped material from provider error text before it can reach a * log/UI. SINGLE SOURCE OF TRUTH — grok-backend.ts and copilot-backend.ts * used to carry their own local copies (`redactGrokTokens`/ * `redactCopilotTokens`); those were folded into this exported superset (no * coverage lost) and the call sites now import this function directly. Any * new provider error path (refresh, exchange, device-poll, data-API call) * MUST run its raw response body through this before it can reach a thrown * Error message or a log line. * * Covers, across all three prior variants: * - key:value / key=value pairs for access_token, refresh_token, id_token, * code, code_verifier, client_secret, and bare token (the Copilot copy's * addition, for `{token: "..."}` exchange-response shapes). * - raw prefixed tokens: ghu_/gho_ (GitHub device-code), ghp_ (Copilot's * addition, GitHub PAT), ya29. (Google), sk- (OpenAI-style), xai- (xAI API * keys — the Grok copy was missing this one). * - `Bearer <token>` headers/strings. * - bare `token <value>` (the Copilot copy's addition, for GitHub's * `Authorization: token ghu_...` scheme appearing in echoed text). */ export function redactTokens(s) { return s .replace(/("?(?:access_token|refresh_token|id_token|code|code_verifier|client_secret|token)"?\s*[:=]\s*"?)[^"'\s,&}]+/gi, "$1<redacted>") .replace(/\b(ghu_|gho_|ghp_|ya29\.|sk-|xai-)[A-Za-z0-9._-]+/g, "$1<redacted>") .replace(/\bBearer\s+[A-Za-z0-9._-]+/gi, "Bearer <redacted>") .replace(/\btoken\s+[A-Za-z0-9._-]+/gi, "token <redacted>"); } /** S256 PKCE pair. verifier: 43-128 chars unreserved; challenge = base64url(sha256(verifier)). */ export function pkcePair() { const verifier = b64url(randomBytes(32)); // 43 base64url chars, all in the unreserved set const challenge = createHash("sha256").update(verifier).digest("base64url"); return { verifier, challenge }; } /** Throw unless `url` is HTTPS on an allowlisted host (exact host or a subdomain of one). */ export function assertAllowedTokenHost(url, allowlist) { let u; try { u = new URL(url); } catch { throw new ValidationError(`OAuth: malformed URL "${String(url).slice(0, 80)}"`); } if (u.protocol !== "https:") throw new ValidationError(`OAuth: refusing non-HTTPS token host "${u.protocol}"`); const host = u.hostname.toLowerCase(); const ok = allowlist.some((a) => { const base = a.toLowerCase(); return host === base || host.endsWith(`.${base}`); }); if (!ok) throw new ValidationError(`OAuth: token host "${host}" not in allowlist [${allowlist.join(", ")}]`); } async function defaultOpenBrowser(url) { const { spawn } = await import("node:child_process"); const cmd = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open"; const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; try { spawn(cmd, args, { detached: true, stdio: "ignore" }).unref(); } catch { logger.warn(`[oauth] could not open a browser automatically — visit: ${url}`); } } /** * Loopback authorization-code + PKCE. Binds 127.0.0.1:cfg.loopbackPort, opens the * browser to the authorize URL, resolves when the provider redirects back with a * matching state, exchanges the code, and always tears the listener down. */ export function runLoopbackPKCE(cfg, deps = {}) { const fetchFn = deps.fetch ?? fetch; const openBrowser = deps.openBrowser ?? defaultOpenBrowser; const port = cfg.loopbackPort ?? 0; const redirectPath = cfg.redirectPath ?? "/auth/callback"; const { verifier, challenge } = pkcePair(); const state = b64url(randomBytes(16)); return new Promise((resolve, reject) => { let settled = false; const done = (fn) => { if (settled) return; settled = true; clearTimeout(timer); server.close(); fn(); }; const server = createServer(async (req, res) => { try { const url = new URL(req.url ?? "/", `http://127.0.0.1:${addrPort}`); if (url.pathname !== redirectPath) { res.writeHead(404).end("not found"); return; } const code = url.searchParams.get("code"); const gotState = url.searchParams.get("state"); if (!gotState || gotState !== state) { res.writeHead(400).end("state mismatch"); done(() => reject(new ValidationError("OAuth: callback state did not match — aborting (possible CSRF)."))); return; } if (!code) { res.writeHead(400).end("missing code"); done(() => reject(new ValidationError("OAuth: callback missing authorization code."))); return; } res.writeHead(200, { "Content-Type": "text/html" }).end("<html><body style='font:14px system-ui;padding:2rem'>Finishing sign-in — you can close this tab and return to ComfyUI.</body></html>"); // Exchange the code. assertAllowedTokenHost(cfg.tokenUrl, cfg.apiHostAllowlist); const body = new URLSearchParams({ grant_type: "authorization_code", code, client_id: cfg.clientId, code_verifier: verifier, redirect_uri: redirectUri, }); const tokRes = await fetchFn(cfg.tokenUrl, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), signal: AbortSignal.timeout(30_000), }); const text = await tokRes.text(); if (!tokRes.ok) { done(() => reject(new ValidationError(`OAuth token exchange failed (${tokRes.status}): ${redactTokens(text).slice(0, 300)}`))); return; } const raw = JSON.parse(text); const access = String(raw.access_token ?? "").trim(); if (!access) { done(() => reject(new ValidationError("OAuth token exchange response missing access_token."))); return; } done(() => resolve({ access_token: access, refresh_token: raw.refresh_token ? String(raw.refresh_token) : undefined, id_token: raw.id_token ? String(raw.id_token) : undefined, expires_in: typeof raw.expires_in === "number" ? raw.expires_in : undefined, raw, })); } catch (err) { done(() => reject(err instanceof Error ? err : new ValidationError(String(err)))); } }); let addrPort = port; let redirectUri = ""; const timer = setTimeout(() => done(() => reject(new ValidationError("OAuth sign-in timed out (5 min) — no callback received."))), 5 * 60_000); server.on("error", (err) => done(() => reject(err))); server.listen(port, "127.0.0.1", () => { const addr = server.address(); addrPort = typeof addr === "object" && addr ? addr.port : port; redirectUri = `http://127.0.0.1:${addrPort}${redirectPath}`; const authUrl = new URL(cfg.authorizeUrl); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("client_id", cfg.clientId); authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("scope", cfg.scopes.join(" ")); authUrl.searchParams.set("state", state); authUrl.searchParams.set("code_challenge", challenge); authUrl.searchParams.set("code_challenge_method", "S256"); Promise.resolve(openBrowser(authUrl.toString())).catch((err) => done(() => reject(err instanceof Error ? err : new ValidationError(String(err))))); }); }); } /** * Device-code step 1: request a device/user code pair from `cfg.deviceCodeUrl`. * The caller shows `user_code` + `verification_url` to the user, then calls * `pollDeviceToken` to wait for them to complete sign-in in their browser. */ export async function beginDeviceCode(cfg, deps = {}) { const fetchFn = deps.fetch ?? fetch; if (!cfg.deviceCodeUrl) throw new ValidationError(`${cfg.label}: no device-code endpoint configured.`); assertAllowedTokenHost(cfg.deviceCodeUrl, cfg.apiHostAllowlist); const res = await fetchFn(cfg.deviceCodeUrl, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: cfg.clientId, scope: cfg.scopes.join(" ") }).toString(), signal: AbortSignal.timeout(30_000), }); const text = await res.text(); if (!res.ok) throw new ValidationError(`${cfg.label} device-code request failed (${res.status}): ${redactTokens(text).slice(0, 200)}`); const j = JSON.parse(text); const user_code = String(j.user_code ?? ""); const verification_url = String(j.verification_uri ?? j.verification_url ?? ""); const device_code = String(j.device_code ?? ""); if (!user_code || !verification_url || !device_code) throw new ValidationError(`${cfg.label} device-code response incomplete.`); return { user_code, verification_url, device_code, interval: typeof j.interval === "number" ? j.interval : 5, expires_in: typeof j.expires_in === "number" ? j.expires_in : 900, }; } /** * Device-code step 2: poll `cfg.tokenUrl` until the user completes sign-in * (or expiry). Honors `authorization_pending` (keep polling at the current * interval) and `slow_down` (grow the interval) per RFC 8628. When * `deps.now` is injected (test mode), sleeps resolve immediately so tests * don't actually wait, and the poll loop is bounded so a fixed injected * clock can't spin forever. */ export async function pollDeviceToken(cfg, deviceCode, deps = {}) { const fetchFn = deps.fetch ?? fetch; assertAllowedTokenHost(cfg.tokenUrl, cfg.apiHostAllowlist); const testMode = typeof deps.now === "function"; const sleep = (ms) => new Promise((r) => setTimeout(r, testMode ? 0 : ms)); const deadline = (deps.now?.() ?? Date.now()) + 15 * 60_000; let intervalMs = 5000; // Bounded loop so a test with a fixed injected now() can't spin forever. for (let i = 0; i < 1000; i++) { if ((deps.now?.() ?? Date.now()) > deadline) throw new ValidationError(`${cfg.label} device sign-in expired.`); const res = await fetchFn(cfg.tokenUrl, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: cfg.clientId, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code", }).toString(), signal: AbortSignal.timeout(30_000), }); if (!res.ok) throw new ValidationError(`${cfg.label} device poll failed (${res.status}): ${redactTokens(await res.text()).slice(0, 200)}`); const j = JSON.parse(await res.text()); const access = String(j.access_token ?? "").trim(); if (access) { return { access_token: access, refresh_token: j.refresh_token ? String(j.refresh_token) : undefined, expires_in: typeof j.expires_in === "number" ? j.expires_in : undefined, raw: j, }; } const err = String(j.error ?? ""); if (err === "authorization_pending") { await sleep(intervalMs); continue; } if (err === "slow_down") { intervalMs += 5000; await sleep(intervalMs); continue; } throw new ValidationError(`${cfg.label} device sign-in failed: ${err || "unknown error"}`); } throw new ValidationError(`${cfg.label} device sign-in did not complete.`); } const codexTokenFile = join(homedir(), ".codex", "auth.json"); const grokTokenFile = join(homedir(), ".grok", "auth.json"); const copilotTokenFile = join(homedir(), ".comfyui-mcp", "copilot-auth.json"); /** Provider registry. Codex always present; grok added ONLY if Task 1 was GO * (fill in clientId/scopes from the recorded decision); copilot in Task 3. */ export const OAUTH_PROVIDERS = { codex: { id: "codex", label: "ChatGPT", kind: "loopback_pkce", authorizeUrl: "https://auth.openai.com/oauth/authorize", tokenUrl: "https://auth.openai.com/oauth/token", clientId: "app_EMoamEEZ73f0CkXaXp7hrann", scopes: ["openid", "profile", "email", "offline_access"], loopbackPort: 1455, redirectPath: "/auth/callback", tokenFile: codexTokenFile, apiHostAllowlist: ["auth.openai.com", "chatgpt.com"], }, // grok: Task 1 recorded GO — clientId/scopes/endpoints from the recorded decision. grok: { id: "grok", label: "Grok", kind: "loopback_pkce", authorizeUrl: "https://auth.x.ai/oauth2/authorize", tokenUrl: "https://auth.x.ai/oauth2/token", clientId: "b1a00492-073a-47ea-816f-4c329264a828", scopes: ["openid", "profile", "email", "offline_access", "grok-cli:access", "api:access"], loopbackPort: 56121, redirectPath: "/callback", tokenFile: grokTokenFile, apiHostAllowlist: ["x.ai"], }, // copilot: device-code only (Copilot's other flows don't work outside VS Code) — EXPERIMENTAL, ToS risk. copilot: { id: "copilot", label: "GitHub Copilot", kind: "device_code", authorizeUrl: "", tokenUrl: "https://github.com/login/oauth/access_token", deviceCodeUrl: "https://github.com/login/device/code", clientId: "Iv1.b507a08c87ecfe98", // VS Code Copilot extension id — EXPERIMENTAL, ToS risk scopes: ["read:user"], tokenFile: copilotTokenFile, apiHostAllowlist: ["github.com", "githubcopilot.com"], experimental: true, }, }; export { grokTokenFile, copilotTokenFile }; //# sourceMappingURL=oauth-flow.js.map