Skip to content

Commit d3e6743

Browse files
Merge pull request #9 from modelstudioai/feat/auto-set-apikey
Refactor console login and implement auto-request for API key
2 parents 765fa23 + a490969 commit d3e6743

2 files changed

Lines changed: 438 additions & 290 deletions

File tree

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
1+
import { execFile } from "node:child_process";
2+
import { randomBytes } from "node:crypto";
3+
import http from "node:http";
4+
5+
import {
6+
BailianError,
7+
ExitCode,
8+
getConfigPath,
9+
readConfigFile,
10+
writeConfigFile,
11+
} from "bailian-cli-core";
12+
13+
const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
14+
const MAX_AUTH_CALLBACK_BODY = 65536;
15+
16+
const DEFAULT_CONSOLE_ORIGIN = "https://bailian.console.aliyun.com";
17+
18+
export function resolveConsoleOrigin(): string {
19+
return process.env.BAILIAN_CONSOLE_ORIGIN || DEFAULT_CONSOLE_ORIGIN;
20+
}
21+
22+
function readBodyBounded(req: http.IncomingMessage): Promise<string> {
23+
return new Promise((resolve, reject) => {
24+
let size = 0;
25+
const chunks: Buffer[] = [];
26+
req.on("data", (chunk: Buffer) => {
27+
size += chunk.length;
28+
if (size > MAX_AUTH_CALLBACK_BODY) {
29+
reject(new Error("payload too large"));
30+
return;
31+
}
32+
chunks.push(chunk);
33+
});
34+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
35+
req.on("error", reject);
36+
});
37+
}
38+
39+
function requestContentType(req: http.IncomingMessage): string {
40+
const h = req.headers["content-type"];
41+
if (Array.isArray(h)) return h[0] ?? "";
42+
return typeof h === "string" ? h : "";
43+
}
44+
45+
function multipartBoundary(contentType: string): string | null {
46+
const parts = contentType.split(";");
47+
for (const p of parts) {
48+
const s = p.trim();
49+
if (!s.toLowerCase().startsWith("boundary=")) continue;
50+
let b = s.slice("boundary=".length).trim();
51+
if ((b.startsWith('"') && b.endsWith('"')) || (b.startsWith("'") && b.endsWith("'"))) {
52+
b = b.slice(1, -1);
53+
}
54+
return b.length > 0 ? b : null;
55+
}
56+
return null;
57+
}
58+
59+
function parseAccessTokenFromMultipart(raw: string, boundaryValue: string): string | null {
60+
const delim = `--${boundaryValue}`;
61+
const segments = raw.split(delim);
62+
for (let i = 1; i < segments.length; i++) {
63+
const part = segments[i]!;
64+
if (!/name\s*=\s*["'](?:access_token|accessToken)["']/i.test(part)) continue;
65+
const sep = part.match(/\r\n\r\n|\n\n/);
66+
if (!sep || sep.index === undefined) continue;
67+
let value = part.slice(sep.index + sep[0].length);
68+
value = value
69+
.replace(/(?:\r\n)+$/g, "")
70+
.replace(/\n+$/g, "")
71+
.trim();
72+
if (value) return value;
73+
}
74+
return null;
75+
}
76+
77+
function tokenFieldFromRecord(o: Record<string, unknown>): string | null {
78+
for (const k of ["access_token", "accessToken"]) {
79+
const v = o[k];
80+
if (typeof v === "string" && v.trim()) return v.trim();
81+
}
82+
return null;
83+
}
84+
85+
function apiKeyFieldFromRecord(o: Record<string, unknown>): string | null {
86+
for (const k of ["api_key", "apiKey"]) {
87+
const v = o[k];
88+
if (typeof v === "string" && v.trim()) return v.trim();
89+
}
90+
return null;
91+
}
92+
93+
function parseAccessTokenFromJsonText(text: string): string | null {
94+
let t = text.trim();
95+
if (t.charCodeAt(0) === 0xfeff) t = t.slice(1);
96+
if (!t) return null;
97+
let j: unknown;
98+
try {
99+
j = JSON.parse(t);
100+
} catch {
101+
return null;
102+
}
103+
if (!j || typeof j !== "object" || Array.isArray(j)) return null;
104+
const o = j as Record<string, unknown>;
105+
const direct = tokenFieldFromRecord(o);
106+
if (direct) return direct;
107+
const data = o.data;
108+
if (data && typeof data === "object" && !Array.isArray(data)) {
109+
const inner = tokenFieldFromRecord(data as Record<string, unknown>);
110+
if (inner) return inner;
111+
}
112+
return null;
113+
}
114+
115+
function parseAccessTokenFromRawBody(raw: string, contentType: string): string | null {
116+
const ct = contentType.toLowerCase();
117+
if (!raw.trim()) return null;
118+
119+
if (ct.includes("multipart/form-data")) {
120+
const b = multipartBoundary(contentType);
121+
if (b) {
122+
const tok = parseAccessTokenFromMultipart(raw, b);
123+
if (tok) return tok;
124+
}
125+
}
126+
127+
if (ct.includes("application/json") || ct.includes("text/json")) {
128+
const t = parseAccessTokenFromJsonText(raw);
129+
if (t) return t;
130+
}
131+
132+
if (ct.includes("application/x-www-form-urlencoded")) {
133+
try {
134+
const params = new URLSearchParams(raw.trim());
135+
const v = params.get("access_token") ?? params.get("accessToken");
136+
if (v?.trim()) return v.trim();
137+
} catch {
138+
/* */
139+
}
140+
}
141+
142+
// Fallbacks when Content-Type is missing or nonstandard (many fetch() callers omit it).
143+
const jsonTok = parseAccessTokenFromJsonText(raw);
144+
if (jsonTok) return jsonTok;
145+
try {
146+
const params = new URLSearchParams(raw.trim());
147+
const v = params.get("access_token") ?? params.get("accessToken");
148+
if (v?.trim()) return v.trim();
149+
} catch {
150+
/* */
151+
}
152+
const b = multipartBoundary(contentType);
153+
if (b) {
154+
const tok = parseAccessTokenFromMultipart(raw, b);
155+
if (tok) return tok;
156+
}
157+
return null;
158+
}
159+
160+
function parseApiKeyFromJsonText(text: string): string | null {
161+
let t = text.trim();
162+
if (t.charCodeAt(0) === 0xfeff) t = t.slice(1);
163+
if (!t) return null;
164+
let j: unknown;
165+
try {
166+
j = JSON.parse(t);
167+
} catch {
168+
return null;
169+
}
170+
if (!j || typeof j !== "object" || Array.isArray(j)) return null;
171+
const o = j as Record<string, unknown>;
172+
const direct = apiKeyFieldFromRecord(o);
173+
if (direct) return direct;
174+
const data = o.data;
175+
if (data && typeof data === "object" && !Array.isArray(data)) {
176+
const inner = apiKeyFieldFromRecord(data as Record<string, unknown>);
177+
if (inner) return inner;
178+
}
179+
return null;
180+
}
181+
182+
function parseApiKeyFromRawBody(raw: string, contentType: string): string | null {
183+
const ct = contentType.toLowerCase();
184+
if (!raw.trim()) return null;
185+
186+
if (ct.includes("application/json") || ct.includes("text/json")) {
187+
const t = parseApiKeyFromJsonText(raw);
188+
if (t) return t;
189+
}
190+
191+
if (ct.includes("application/x-www-form-urlencoded")) {
192+
try {
193+
const params = new URLSearchParams(raw.trim());
194+
const v = params.get("api_key") ?? params.get("apiKey");
195+
if (v?.trim()) return v.trim();
196+
} catch {
197+
/* */
198+
}
199+
}
200+
201+
const jsonTok = parseApiKeyFromJsonText(raw);
202+
if (jsonTok) return jsonTok;
203+
try {
204+
const params = new URLSearchParams(raw.trim());
205+
const v = params.get("api_key") ?? params.get("apiKey");
206+
if (v?.trim()) return v.trim();
207+
} catch {
208+
/* */
209+
}
210+
return null;
211+
}
212+
213+
interface CallbackCredentials {
214+
accessToken: string | null;
215+
apiKey: string | null;
216+
}
217+
218+
async function extractCredentialsFromRequest(
219+
req: http.IncomingMessage,
220+
): Promise<CallbackCredentials> {
221+
const u = new URL(req.url ?? "/", "http://127.0.0.1");
222+
const accessTokenFromQuery =
223+
u.searchParams.get("access_token") ?? u.searchParams.get("accessToken");
224+
const apiKeyFromQuery = u.searchParams.get("api_key") ?? u.searchParams.get("apiKey");
225+
226+
const m = req.method ?? "GET";
227+
if (m !== "POST" && m !== "PUT" && m !== "PATCH") {
228+
return {
229+
accessToken: accessTokenFromQuery?.trim() || null,
230+
apiKey: apiKeyFromQuery?.trim() || null,
231+
};
232+
}
233+
234+
const contentType = requestContentType(req);
235+
let raw: string;
236+
try {
237+
raw = await readBodyBounded(req);
238+
} catch {
239+
return {
240+
accessToken: accessTokenFromQuery?.trim() || null,
241+
apiKey: apiKeyFromQuery?.trim() || null,
242+
};
243+
}
244+
245+
const accessToken = accessTokenFromQuery?.trim() || parseAccessTokenFromRawBody(raw, contentType);
246+
const apiKey = apiKeyFromQuery?.trim() || parseApiKeyFromRawBody(raw, contentType);
247+
return { accessToken, apiKey };
248+
}
249+
250+
function listenServerOnFreeLocalPort(server: http.Server): Promise<number> {
251+
return new Promise((resolve, reject) => {
252+
const onErr = (e: Error) => reject(e);
253+
server.once("error", onErr);
254+
server.listen({ port: 0, host: "127.0.0.1", exclusive: true }, () => {
255+
server.off("error", onErr);
256+
const addr = server.address();
257+
if (!addr || typeof addr === "string") {
258+
reject(new Error("Expected TCP socket address"));
259+
return;
260+
}
261+
resolve(addr.port);
262+
});
263+
});
264+
}
265+
266+
function openInBrowser(url: string): Promise<void> {
267+
const platform = process.platform;
268+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
269+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
270+
271+
return new Promise((resolve, reject) => {
272+
execFile(cmd, args, { windowsHide: true }, (err) => {
273+
if (err) reject(err);
274+
else resolve();
275+
});
276+
});
277+
}
278+
279+
export async function runConsoleLogin(
280+
consoleOrigin: string,
281+
opts?: { needApiKey?: boolean; onApiKey?: (key: string) => Promise<void> },
282+
): Promise<void> {
283+
const state = randomBytes(16).toString("hex");
284+
let callbackError: unknown;
285+
const server = http.createServer(async (req, res) => {
286+
try {
287+
if (req.method === "OPTIONS") {
288+
res.writeHead(204, {
289+
"Access-Control-Allow-Origin": "*",
290+
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, OPTIONS",
291+
"Access-Control-Allow-Headers": "Content-Type",
292+
});
293+
res.end();
294+
return;
295+
}
296+
297+
const u = new URL(req.url ?? "/", "http://127.0.0.1");
298+
if (u.searchParams.get("state") !== state) {
299+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
300+
res.end("bad state\n");
301+
return;
302+
}
303+
304+
const { accessToken, apiKey } = await extractCredentialsFromRequest(req);
305+
306+
if (accessToken || apiKey) {
307+
try {
308+
if (accessToken) {
309+
const existing = readConfigFile() as Record<string, unknown>;
310+
existing.access_token = accessToken;
311+
await writeConfigFile(existing);
312+
process.stderr.write(`access_token saved to ${getConfigPath()}\n`);
313+
}
314+
if (apiKey && opts?.onApiKey) {
315+
await opts.onApiKey(apiKey);
316+
}
317+
} catch (err: unknown) {
318+
callbackError = err;
319+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
320+
res.end("Failed to save credentials\n");
321+
server.close();
322+
return;
323+
}
324+
}
325+
326+
res.writeHead(200, {
327+
"Content-Type": "text/plain; charset=utf-8",
328+
"Access-Control-Allow-Origin": "*",
329+
});
330+
res.end("OK\n");
331+
332+
if (accessToken || apiKey) {
333+
server.close();
334+
}
335+
} catch {
336+
res.statusCode = 500;
337+
res.end();
338+
}
339+
});
340+
341+
let port: number;
342+
try {
343+
port = await listenServerOnFreeLocalPort(server);
344+
} catch (e: unknown) {
345+
const msg = e instanceof Error ? e.message : String(e);
346+
throw new BailianError(
347+
`Could not bind to 127.0.0.1 (no free port or permission denied): ${msg}`,
348+
ExitCode.USAGE,
349+
);
350+
}
351+
352+
let loginUrl = `${consoleOrigin}/console-login?notice=127.0.0.1:${port}?state=${encodeURIComponent(state)}`;
353+
if (opts?.needApiKey) {
354+
loginUrl += "&needapikey=true";
355+
}
356+
357+
try {
358+
await openInBrowser(loginUrl);
359+
process.stderr.write(
360+
"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",
361+
);
362+
} catch (e: unknown) {
363+
const msg = e instanceof Error ? e.message : String(e);
364+
process.stderr.write(
365+
`Could not open the default browser (${msg}). Open this URL manually:\n\n`,
366+
);
367+
process.stdout.write(`${loginUrl}\n`);
368+
process.stderr.write(
369+
"\nThis process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n",
370+
);
371+
}
372+
373+
await new Promise<void>((resolve, reject) => {
374+
let finished = false;
375+
const done = () => {
376+
if (finished) return;
377+
finished = true;
378+
clearTimeout(timer);
379+
resolve();
380+
};
381+
const timer = setTimeout(() => {
382+
server.close();
383+
}, CONSOLE_LOGIN_TIMEOUT_MS);
384+
385+
server.once("close", done);
386+
server.once("error", (err) => {
387+
clearTimeout(timer);
388+
if (!finished) {
389+
finished = true;
390+
reject(err);
391+
}
392+
});
393+
});
394+
395+
if (callbackError) {
396+
throw callbackError;
397+
}
398+
}

0 commit comments

Comments
 (0)