diff --git a/packages/cli/src/commands/auth/login-console.ts b/packages/cli/src/commands/auth/login-console.ts new file mode 100644 index 0000000..e350d1b --- /dev/null +++ b/packages/cli/src/commands/auth/login-console.ts @@ -0,0 +1,388 @@ +import { execFile } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import http from "node:http"; + +import { + BailianError, + ExitCode, + getConfigPath, + readConfigFile, + writeConfigFile, +} from "bailian-cli-core"; + +const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000; +const MAX_AUTH_CALLBACK_BODY = 65536; + +const DEFAULT_CONSOLE_ORIGIN = "https://bailian.console.aliyun.com"; + +export function resolveConsoleOrigin(): string { + return process.env.BAILIAN_CONSOLE_ORIGIN || DEFAULT_CONSOLE_ORIGIN; +} + +function readBodyBounded(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_AUTH_CALLBACK_BODY) { + reject(new Error("payload too large")); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function requestContentType(req: http.IncomingMessage): string { + const h = req.headers["content-type"]; + if (Array.isArray(h)) return h[0] ?? ""; + return typeof h === "string" ? h : ""; +} + +function multipartBoundary(contentType: string): string | null { + const parts = contentType.split(";"); + for (const p of parts) { + const s = p.trim(); + if (!s.toLowerCase().startsWith("boundary=")) continue; + let b = s.slice("boundary=".length).trim(); + if ((b.startsWith('"') && b.endsWith('"')) || (b.startsWith("'") && b.endsWith("'"))) { + b = b.slice(1, -1); + } + return b.length > 0 ? b : null; + } + return null; +} + +function parseAccessTokenFromMultipart(raw: string, boundaryValue: string): string | null { + const delim = `--${boundaryValue}`; + const segments = raw.split(delim); + for (let i = 1; i < segments.length; i++) { + const part = segments[i]!; + if (!/name\s*=\s*["'](?:access_token|accessToken)["']/i.test(part)) continue; + const sep = part.match(/\r\n\r\n|\n\n/); + if (!sep || sep.index === undefined) continue; + let value = part.slice(sep.index + sep[0].length); + value = value + .replace(/(?:\r\n)+$/g, "") + .replace(/\n+$/g, "") + .trim(); + if (value) return value; + } + return null; +} + +function tokenFieldFromRecord(o: Record): string | null { + for (const k of ["access_token", "accessToken"]) { + const v = o[k]; + if (typeof v === "string" && v.trim()) return v.trim(); + } + return null; +} + +function apiKeyFieldFromRecord(o: Record): string | null { + for (const k of ["api_key", "apiKey"]) { + const v = o[k]; + if (typeof v === "string" && v.trim()) return v.trim(); + } + return null; +} + +function parseAccessTokenFromJsonText(text: string): string | null { + let t = text.trim(); + if (t.charCodeAt(0) === 0xfeff) t = t.slice(1); + if (!t) return null; + let j: unknown; + try { + j = JSON.parse(t); + } catch { + return null; + } + if (!j || typeof j !== "object" || Array.isArray(j)) return null; + const o = j as Record; + const direct = tokenFieldFromRecord(o); + if (direct) return direct; + const data = o.data; + if (data && typeof data === "object" && !Array.isArray(data)) { + const inner = tokenFieldFromRecord(data as Record); + if (inner) return inner; + } + return null; +} + +function parseAccessTokenFromRawBody(raw: string, contentType: string): string | null { + const ct = contentType.toLowerCase(); + if (!raw.trim()) return null; + + if (ct.includes("multipart/form-data")) { + const b = multipartBoundary(contentType); + if (b) { + const tok = parseAccessTokenFromMultipart(raw, b); + if (tok) return tok; + } + } + + if (ct.includes("application/json") || ct.includes("text/json")) { + const t = parseAccessTokenFromJsonText(raw); + if (t) return t; + } + + if (ct.includes("application/x-www-form-urlencoded")) { + try { + const params = new URLSearchParams(raw.trim()); + const v = params.get("access_token") ?? params.get("accessToken"); + if (v?.trim()) return v.trim(); + } catch { + /* */ + } + } + + // Fallbacks when Content-Type is missing or nonstandard (many fetch() callers omit it). + const jsonTok = parseAccessTokenFromJsonText(raw); + if (jsonTok) return jsonTok; + try { + const params = new URLSearchParams(raw.trim()); + const v = params.get("access_token") ?? params.get("accessToken"); + if (v?.trim()) return v.trim(); + } catch { + /* */ + } + const b = multipartBoundary(contentType); + if (b) { + const tok = parseAccessTokenFromMultipart(raw, b); + if (tok) return tok; + } + return null; +} + +function parseApiKeyFromJsonText(text: string): string | null { + let t = text.trim(); + if (t.charCodeAt(0) === 0xfeff) t = t.slice(1); + if (!t) return null; + let j: unknown; + try { + j = JSON.parse(t); + } catch { + return null; + } + if (!j || typeof j !== "object" || Array.isArray(j)) return null; + const o = j as Record; + const direct = apiKeyFieldFromRecord(o); + if (direct) return direct; + const data = o.data; + if (data && typeof data === "object" && !Array.isArray(data)) { + const inner = apiKeyFieldFromRecord(data as Record); + if (inner) return inner; + } + return null; +} + +function parseApiKeyFromRawBody(raw: string, contentType: string): string | null { + const ct = contentType.toLowerCase(); + if (!raw.trim()) return null; + + if (ct.includes("application/json") || ct.includes("text/json")) { + const t = parseApiKeyFromJsonText(raw); + if (t) return t; + } + + if (ct.includes("application/x-www-form-urlencoded")) { + try { + const params = new URLSearchParams(raw.trim()); + const v = params.get("api_key") ?? params.get("apiKey"); + if (v?.trim()) return v.trim(); + } catch { + /* */ + } + } + + const jsonTok = parseApiKeyFromJsonText(raw); + if (jsonTok) return jsonTok; + try { + const params = new URLSearchParams(raw.trim()); + const v = params.get("api_key") ?? params.get("apiKey"); + if (v?.trim()) return v.trim(); + } catch { + /* */ + } + return null; +} + +interface CallbackCredentials { + accessToken: string | null; + apiKey: string | null; +} + +async function extractCredentialsFromRequest( + req: http.IncomingMessage, +): Promise { + const u = new URL(req.url ?? "/", "http://127.0.0.1"); + const accessTokenFromQuery = + u.searchParams.get("access_token") ?? u.searchParams.get("accessToken"); + const apiKeyFromQuery = u.searchParams.get("api_key") ?? u.searchParams.get("apiKey"); + + const m = req.method ?? "GET"; + if (m !== "POST" && m !== "PUT" && m !== "PATCH") { + return { + accessToken: accessTokenFromQuery?.trim() || null, + apiKey: apiKeyFromQuery?.trim() || null, + }; + } + + const contentType = requestContentType(req); + let raw: string; + try { + raw = await readBodyBounded(req); + } catch { + return { + accessToken: accessTokenFromQuery?.trim() || null, + apiKey: apiKeyFromQuery?.trim() || null, + }; + } + + const accessToken = accessTokenFromQuery?.trim() || parseAccessTokenFromRawBody(raw, contentType); + const apiKey = apiKeyFromQuery?.trim() || parseApiKeyFromRawBody(raw, contentType); + return { accessToken, apiKey }; +} + +function listenServerOnFreeLocalPort(server: http.Server): Promise { + return new Promise((resolve, reject) => { + const onErr = (e: Error) => reject(e); + server.once("error", onErr); + server.listen({ port: 0, host: "127.0.0.1", exclusive: true }, () => { + server.off("error", onErr); + const addr = server.address(); + if (!addr || typeof addr === "string") { + reject(new Error("Expected TCP socket address")); + return; + } + resolve(addr.port); + }); + }); +} + +function openInBrowser(url: string): Promise { + const platform = process.platform; + const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; + const args = platform === "win32" ? ["/c", "start", "", url] : [url]; + + return new Promise((resolve, reject) => { + execFile(cmd, args, { windowsHide: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +export async function runConsoleLogin( + consoleOrigin: string, + opts?: { needApiKey?: boolean }, +): Promise { + const state = randomBytes(16).toString("hex"); + const server = http.createServer(async (req, res) => { + try { + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }); + res.end(); + return; + } + + const u = new URL(req.url ?? "/", "http://127.0.0.1"); + if (u.searchParams.get("state") !== state) { + res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("bad state\n"); + return; + } + + const { accessToken, apiKey } = await extractCredentialsFromRequest(req); + + if (accessToken || apiKey) { + try { + const existing = readConfigFile() as Record; + if (accessToken) existing.access_token = accessToken; + if (apiKey) existing.api_key = apiKey; + await writeConfigFile(existing); + if (accessToken) process.stderr.write(`access_token saved to ${getConfigPath()}\n`); + if (apiKey) process.stderr.write(`api_key saved to ${getConfigPath()}\n`); + } catch { + res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Failed to save credentials\n"); + return; + } + } + + res.writeHead(200, { + "Content-Type": "text/plain; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }); + res.end("OK\n"); + + if (accessToken || apiKey) { + server.close(); + } + } catch { + res.statusCode = 500; + res.end(); + } + }); + + let port: number; + try { + port = await listenServerOnFreeLocalPort(server); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + throw new BailianError( + `Could not bind to 127.0.0.1 (no free port or permission denied): ${msg}`, + ExitCode.USAGE, + ); + } + + let loginUrl = `${consoleOrigin}/console-login?notice=127.0.0.1:${port}?state=${encodeURIComponent(state)}`; + if (opts?.needApiKey) { + loginUrl += "&needapikey=true"; + } + + try { + await openInBrowser(loginUrl); + process.stderr.write( + "Opened the login page in your default browser. This process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n", + ); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + process.stderr.write( + `Could not open the default browser (${msg}). Open this URL manually:\n\n`, + ); + process.stdout.write(`${loginUrl}\n`); + process.stderr.write( + "\nThis process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n", + ); + } + + await new Promise((resolve, reject) => { + let finished = false; + const done = () => { + if (finished) return; + finished = true; + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + server.close(); + }, CONSOLE_LOGIN_TIMEOUT_MS); + + server.once("close", done); + server.once("error", (err) => { + clearTimeout(timer); + if (!finished) { + finished = true; + reject(err); + } + }); + }); +} diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 8110705..246858b 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,10 +1,4 @@ -import { execFile } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import http from "node:http"; - import { - BailianError, - ExitCode, chatEndpoint, defineCommand, getConfigPath, @@ -20,193 +14,7 @@ import { printQuickStart } from "../../output/banner.ts"; import { emitBare } from "../../output/output.ts"; import { promptConfirm } from "../../output/prompt.ts"; import { printCurrentCommandHelp } from "../../utils/command-help.ts"; - -const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000; -const MAX_AUTH_CALLBACK_BODY = 65536; - -const DEFAULT_CONSOLE_ORIGIN = "https://bailian.console.aliyun.com"; - -function resolveConsoleOrigin(): string { - return process.env.BAILIAN_CONSOLE_ORIGIN || DEFAULT_CONSOLE_ORIGIN; -} - -function readBodyBounded(req: http.IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let size = 0; - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => { - size += chunk.length; - if (size > MAX_AUTH_CALLBACK_BODY) { - reject(new Error("payload too large")); - return; - } - chunks.push(chunk); - }); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - req.on("error", reject); - }); -} - -function requestContentType(req: http.IncomingMessage): string { - const h = req.headers["content-type"]; - if (Array.isArray(h)) return h[0] ?? ""; - return typeof h === "string" ? h : ""; -} - -function multipartBoundary(contentType: string): string | null { - const parts = contentType.split(";"); - for (const p of parts) { - const s = p.trim(); - if (!s.toLowerCase().startsWith("boundary=")) continue; - let b = s.slice("boundary=".length).trim(); - if ((b.startsWith('"') && b.endsWith('"')) || (b.startsWith("'") && b.endsWith("'"))) { - b = b.slice(1, -1); - } - return b.length > 0 ? b : null; - } - return null; -} - -/** multipart/form-data: find part with name=access_token and return its body. */ -function parseAccessTokenFromMultipart(raw: string, boundaryValue: string): string | null { - const delim = `--${boundaryValue}`; - const segments = raw.split(delim); - for (let i = 1; i < segments.length; i++) { - const part = segments[i]!; - if (!/name\s*=\s*["'](?:access_token|accessToken)["']/i.test(part)) continue; - const sep = part.match(/\r\n\r\n|\n\n/); - if (!sep || sep.index === undefined) continue; - let value = part.slice(sep.index + sep[0].length); - value = value - .replace(/(?:\r\n)+$/g, "") - .replace(/\n+$/g, "") - .trim(); - if (value) return value; - } - return null; -} - -function tokenFieldFromRecord(o: Record): string | null { - for (const k of ["access_token", "accessToken"]) { - const v = o[k]; - if (typeof v === "string" && v.trim()) return v.trim(); - } - return null; -} - -function parseAccessTokenFromJsonText(text: string): string | null { - let t = text.trim(); - if (t.charCodeAt(0) === 0xfeff) t = t.slice(1); - if (!t) return null; - let j: unknown; - try { - j = JSON.parse(t); - } catch { - return null; - } - if (!j || typeof j !== "object" || Array.isArray(j)) return null; - const o = j as Record; - const direct = tokenFieldFromRecord(o); - if (direct) return direct; - const data = o.data; - if (data && typeof data === "object" && !Array.isArray(data)) { - const inner = tokenFieldFromRecord(data as Record); - if (inner) return inner; - } - return null; -} - -function parseAccessTokenFromRawBody(raw: string, contentType: string): string | null { - const ct = contentType.toLowerCase(); - if (!raw.trim()) return null; - - if (ct.includes("multipart/form-data")) { - const b = multipartBoundary(contentType); - if (b) { - const tok = parseAccessTokenFromMultipart(raw, b); - if (tok) return tok; - } - } - - if (ct.includes("application/json") || ct.includes("text/json")) { - const t = parseAccessTokenFromJsonText(raw); - if (t) return t; - } - - if (ct.includes("application/x-www-form-urlencoded")) { - try { - const params = new URLSearchParams(raw.trim()); - const v = params.get("access_token") ?? params.get("accessToken"); - if (v?.trim()) return v.trim(); - } catch { - /* */ - } - } - - // Fallbacks when Content-Type is missing or nonstandard (many fetch() callers omit it). - const jsonTok = parseAccessTokenFromJsonText(raw); - if (jsonTok) return jsonTok; - try { - const params = new URLSearchParams(raw.trim()); - const v = params.get("access_token") ?? params.get("accessToken"); - if (v?.trim()) return v.trim(); - } catch { - /* */ - } - const b = multipartBoundary(contentType); - if (b) { - const tok = parseAccessTokenFromMultipart(raw, b); - if (tok) return tok; - } - return null; -} - -async function extractAccessTokenFromRequest(req: http.IncomingMessage): Promise { - const u = new URL(req.url ?? "/", "http://127.0.0.1"); - const fromQuery = u.searchParams.get("access_token") ?? u.searchParams.get("accessToken"); - if (fromQuery?.trim()) return fromQuery.trim(); - - const m = req.method ?? "GET"; - if (m !== "POST" && m !== "PUT" && m !== "PATCH") return null; - - const contentType = requestContentType(req); - try { - const raw = await readBodyBounded(req); - return parseAccessTokenFromRawBody(raw, contentType); - } catch { - return null; - } -} - -/** Binds to an ephemeral port on loopback; the OS only assigns ports that are free at bind time. */ -function listenServerOnFreeLocalPort(server: http.Server): Promise { - return new Promise((resolve, reject) => { - const onErr = (e: Error) => reject(e); - server.once("error", onErr); - server.listen({ port: 0, host: "127.0.0.1", exclusive: true }, () => { - server.off("error", onErr); - const addr = server.address(); - if (!addr || typeof addr === "string") { - reject(new Error("Expected TCP socket address")); - return; - } - resolve(addr.port); - }); - }); -} - -function openInBrowser(url: string): Promise { - const platform = process.platform; - const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; - const args = platform === "win32" ? ["/c", "start", "", url] : [url]; - - return new Promise((resolve, reject) => { - execFile(cmd, args, { windowsHide: true }, (err) => { - if (err) reject(err); - else resolve(); - }); - }); -} +import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts"; async function validateKeyAndPersist(config: Config, key: string): Promise { process.stderr.write("Testing key... "); @@ -228,110 +36,6 @@ async function validateKeyAndPersist(config: Config, key: string): Promise process.stderr.write(`Saved to ${getConfigPath()}\n`); } -/** Listens on 127.0.0.1: so the console can reach the address passed to the browser. */ -async function runConsoleLogin(consoleOrigin: string): Promise { - const state = randomBytes(16).toString("hex"); - const server = http.createServer(async (req, res) => { - try { - if (req.method === "OPTIONS") { - res.writeHead(204, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - }); - res.end(); - return; - } - - const u = new URL(req.url ?? "/", "http://127.0.0.1"); - if (u.searchParams.get("state") !== state) { - res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("bad state\n"); - return; - } - - const accessToken = await extractAccessTokenFromRequest(req); - - if (accessToken) { - try { - const existing = readConfigFile() as Record; - existing.access_token = accessToken; - await writeConfigFile(existing); - process.stderr.write(`access_token saved to ${getConfigPath()}\n`); - } catch { - res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("Failed to save access_token\n"); - return; - } - } - - res.writeHead(200, { - "Content-Type": "text/plain; charset=utf-8", - "Access-Control-Allow-Origin": "*", - }); - res.end("OK\n"); - - if (accessToken) { - server.close(); - } - } catch { - res.statusCode = 500; - res.end(); - } - }); - - let port: number; - try { - port = await listenServerOnFreeLocalPort(server); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - throw new BailianError( - `Could not bind to 127.0.0.1 (no free port or permission denied): ${msg}`, - ExitCode.USAGE, - ); - } - - const loginUrl = `${consoleOrigin}/console-login?notice=127.0.0.1:${port}?state=${encodeURIComponent(state)}`; - - try { - await openInBrowser(loginUrl); - process.stderr.write( - "Opened the login page in your default browser. This process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n", - ); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write( - `Could not open the default browser (${msg}). Open this URL manually:\n\n`, - ); - process.stdout.write(`${loginUrl}\n`); - process.stderr.write( - "\nThis process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n", - ); - } - - await new Promise((resolve, reject) => { - let finished = false; - const done = () => { - if (finished) return; - finished = true; - clearTimeout(timer); - resolve(); - }; - const timer = setTimeout(() => { - server.close(); - }, CONSOLE_LOGIN_TIMEOUT_MS); - - server.once("close", done); - server.once("error", (err) => { - clearTimeout(timer); - if (!finished) { - finished = true; - reject(err); - } - }); - }); -} - export default defineCommand({ name: "auth login", description: "Authenticate with API key or console browser login (credentials can coexist)", @@ -353,7 +57,8 @@ export default defineCommand({ ); return; } - await runConsoleLogin(resolveConsoleOrigin()); + const hasApiKey = !!(config.apiKey || config.fileApiKey); + await runConsoleLogin(resolveConsoleOrigin(), { needApiKey: !hasApiKey }); return; }