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
JavaScript
// 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