@youkno/edge-cli 1.20.2314

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,527 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import http from "node:http";
6
+
7
+ import { EffectiveConfig } from "./types";
8
+
9
+ const AUTH_DIR = process.env.EDGE_CLI_AUTH_DIR
10
+ ? path.resolve(process.env.EDGE_CLI_AUTH_DIR)
11
+ : path.join(os.homedir(), ".edge-cli-auth");
12
+ const AUTH_FILE = path.join(AUTH_DIR, "accounts.json");
13
+ const DEFAULT_DEVICE_NAME = "edge-cli";
14
+
15
+ type AuthAccount = {
16
+ accessToken: string;
17
+ refreshToken?: string;
18
+ accessExpEpoch?: number;
19
+ refreshExpEpoch?: number;
20
+ };
21
+
22
+ type ScopeData = {
23
+ default?: string;
24
+ accounts: Record<string, AuthAccount>;
25
+ };
26
+
27
+ type AuthDb = {
28
+ scopes: Record<string, ScopeData>;
29
+ };
30
+
31
+ type TokenResponse = {
32
+ accessToken: string;
33
+ refreshToken?: string;
34
+ accessTokenExpiresInSec?: number;
35
+ refreshTokenExpiresInSec?: number;
36
+ };
37
+
38
+ function scopeKey(cfg: EffectiveConfig): string {
39
+ return `${cfg.product}/${cfg.env}`;
40
+ }
41
+
42
+ function ensureAuthDir(): void {
43
+ if (!fs.existsSync(AUTH_DIR)) {
44
+ fs.mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 });
45
+ }
46
+ }
47
+
48
+ function readDb(): AuthDb {
49
+ ensureAuthDir();
50
+ if (!fs.existsSync(AUTH_FILE)) {
51
+ return { scopes: {} };
52
+ }
53
+ try {
54
+ const raw = fs.readFileSync(AUTH_FILE, "utf8");
55
+ const parsed = JSON.parse(raw) as AuthDb;
56
+ if (!parsed.scopes || typeof parsed.scopes !== "object") {
57
+ return { scopes: {} };
58
+ }
59
+ return parsed;
60
+ } catch {
61
+ return { scopes: {} };
62
+ }
63
+ }
64
+
65
+ function writeDb(db: AuthDb): void {
66
+ ensureAuthDir();
67
+ fs.writeFileSync(AUTH_FILE, `${JSON.stringify(db, null, 2)}\n`, { mode: 0o600 });
68
+ }
69
+
70
+ function getScope(db: AuthDb, cfg: EffectiveConfig): ScopeData {
71
+ const key = scopeKey(cfg);
72
+ if (!db.scopes[key]) {
73
+ db.scopes[key] = { default: "", accounts: {} };
74
+ }
75
+ if (!db.scopes[key].accounts) {
76
+ db.scopes[key].accounts = {};
77
+ }
78
+ return db.scopes[key];
79
+ }
80
+
81
+ function nowEpoch(): number {
82
+ return Math.floor(Date.now() / 1000);
83
+ }
84
+
85
+ export function listLogins(cfg: EffectiveConfig): Array<{ email: string; isDefault: boolean }> {
86
+ const db = readDb();
87
+ const scope = getScope(db, cfg);
88
+ return Object.keys(scope.accounts)
89
+ .sort((a, b) => a.localeCompare(b))
90
+ .map((email) => ({ email, isDefault: scope.default === email }));
91
+ }
92
+
93
+ export function setDefaultLogin(cfg: EffectiveConfig, email: string): void {
94
+ const db = readDb();
95
+ const scope = getScope(db, cfg);
96
+ if (!scope.accounts[email]) {
97
+ throw new Error(`Account not found for ${cfg.product}/${cfg.env}: ${email}`);
98
+ }
99
+ scope.default = email;
100
+ writeDb(db);
101
+ }
102
+
103
+ function authStateHeader(cfg: EffectiveConfig): string {
104
+ return `${cfg.product}.${cfg.env}`;
105
+ }
106
+
107
+ function authDeviceId(): string {
108
+ return `edge-cli:${os.hostname() || "unknown-host"}`;
109
+ }
110
+
111
+ function decodeJwtPayload(token: string): Record<string, unknown> {
112
+ const parts = token.split(".");
113
+ if (parts.length < 2) {
114
+ return {};
115
+ }
116
+ const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
117
+ const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
118
+ const decoded = Buffer.from(b64 + pad, "base64").toString("utf8");
119
+ try {
120
+ return JSON.parse(decoded) as Record<string, unknown>;
121
+ } catch {
122
+ return {};
123
+ }
124
+ }
125
+
126
+ function extractEmailFromToken(accessToken: string): string {
127
+ const payload = decodeJwtPayload(accessToken);
128
+ const email = payload.email;
129
+ if (typeof email === "string" && email.trim()) {
130
+ return email.trim();
131
+ }
132
+ return "unknown@local";
133
+ }
134
+
135
+ function getAuthHeaderFromOverride(auth?: string): string | undefined {
136
+ if (!auth) {
137
+ return undefined;
138
+ }
139
+ return `Basic ${Buffer.from(auth).toString("base64")}`;
140
+ }
141
+
142
+ async function requestTokenEndpoint(
143
+ cfg: EffectiveConfig,
144
+ endpoint: "exchange" | "refresh" | "logout",
145
+ payload: Record<string, unknown>
146
+ ): Promise<TokenResponse | undefined> {
147
+ const url = `${cfg.baseUrl.replace(/\/$/, "")}/api/v1/auth/${endpoint}`;
148
+ const response = await fetch(url, {
149
+ method: "POST",
150
+ headers: {
151
+ "Content-Type": "application/json",
152
+ "X-edge-state": authStateHeader(cfg)
153
+ },
154
+ body: JSON.stringify(payload)
155
+ });
156
+
157
+ if (!response.ok) {
158
+ const body = await response.text();
159
+ throw new Error(`Auth ${endpoint} failed (${response.status}): ${body}`);
160
+ }
161
+
162
+ if (endpoint === "logout") {
163
+ return undefined;
164
+ }
165
+
166
+ return (await response.json()) as TokenResponse;
167
+ }
168
+
169
+ async function authExchangeFirebaseToken(cfg: EffectiveConfig, firebaseToken: string): Promise<TokenResponse> {
170
+ const resp = await requestTokenEndpoint(cfg, "exchange", {
171
+ firebaseToken,
172
+ deviceId: authDeviceId(),
173
+ deviceName: DEFAULT_DEVICE_NAME
174
+ });
175
+ if (!resp?.accessToken) {
176
+ throw new Error("Token exchange did not return accessToken");
177
+ }
178
+ return resp;
179
+ }
180
+
181
+ async function authRefreshToken(cfg: EffectiveConfig, refreshToken: string): Promise<TokenResponse> {
182
+ const resp = await requestTokenEndpoint(cfg, "refresh", {
183
+ refreshToken,
184
+ deviceId: authDeviceId(),
185
+ deviceName: DEFAULT_DEVICE_NAME
186
+ });
187
+ if (!resp?.accessToken) {
188
+ throw new Error("Token refresh did not return accessToken");
189
+ }
190
+ return resp;
191
+ }
192
+
193
+ async function authLogoutRefreshToken(cfg: EffectiveConfig, refreshToken: string): Promise<void> {
194
+ await requestTokenEndpoint(cfg, "logout", { refreshToken });
195
+ }
196
+
197
+ export async function logout(cfg: EffectiveConfig, email?: string): Promise<string> {
198
+ const db = readDb();
199
+ const scope = getScope(db, cfg);
200
+ const target = email ?? scope.default;
201
+ if (!target) {
202
+ throw new Error(`No account to logout for ${cfg.product}/${cfg.env}`);
203
+ }
204
+ const account = scope.accounts[target];
205
+ if (!account) {
206
+ throw new Error(`Account not found for ${cfg.product}/${cfg.env}: ${target}`);
207
+ }
208
+
209
+ if (account.refreshToken) {
210
+ try {
211
+ await authLogoutRefreshToken(cfg, account.refreshToken);
212
+ } catch {
213
+ // best effort
214
+ }
215
+ }
216
+
217
+ delete scope.accounts[target];
218
+ if (scope.default === target) {
219
+ const left = Object.keys(scope.accounts).sort((a, b) => a.localeCompare(b));
220
+ scope.default = left[0] ?? "";
221
+ }
222
+ writeDb(db);
223
+ return target;
224
+ }
225
+
226
+ function storeTokens(cfg: EffectiveConfig, email: string, tokenResp: TokenResponse): void {
227
+ const db = readDb();
228
+ const scope = getScope(db, cfg);
229
+ const now = nowEpoch();
230
+
231
+ scope.accounts[email] = {
232
+ accessToken: tokenResp.accessToken,
233
+ refreshToken: tokenResp.refreshToken,
234
+ accessExpEpoch: now + (tokenResp.accessTokenExpiresInSec ?? 0),
235
+ refreshExpEpoch: tokenResp.refreshToken ? now + (tokenResp.refreshTokenExpiresInSec ?? 0) : 0
236
+ };
237
+ scope.default = email;
238
+ writeDb(db);
239
+ }
240
+
241
+ function parseCallbackQuery(queryString: string): {
242
+ email?: string;
243
+ accessToken?: string;
244
+ refreshToken?: string;
245
+ firebaseToken?: string;
246
+ code?: string;
247
+ } {
248
+ const query = queryString.startsWith("?") ? queryString.slice(1) : queryString;
249
+ const params = new URLSearchParams(query);
250
+ const read = (...keys: string[]) => {
251
+ for (const k of keys) {
252
+ const value = params.get(k);
253
+ if (value) {
254
+ return value;
255
+ }
256
+ }
257
+ return undefined;
258
+ };
259
+
260
+ return {
261
+ email: read("email", "user", "username", "userEmail"),
262
+ accessToken: read("accessToken", "access_token", "jwt", "token"),
263
+ refreshToken: read("refreshToken", "refresh_token"),
264
+ firebaseToken: read("firebaseToken", "firebase_token", "idToken", "id_token"),
265
+ code: read("code", "authCode")
266
+ };
267
+ }
268
+
269
+ function openBrowser(url: string): boolean {
270
+ const platform = process.platform;
271
+ let command: string;
272
+ let args: string[];
273
+
274
+ if (platform === "darwin") {
275
+ command = "open";
276
+ args = [url];
277
+ } else if (platform === "win32") {
278
+ command = "cmd";
279
+ args = ["/c", "start", "", url];
280
+ } else {
281
+ command = "xdg-open";
282
+ args = [url];
283
+ }
284
+
285
+ try {
286
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
287
+ child.unref();
288
+ return true;
289
+ } catch {
290
+ return false;
291
+ }
292
+ }
293
+
294
+ function startAuthListener(timeoutSec: number): Promise<{ redirectUrl: string; callback: Promise<string> }> {
295
+ const tokenKeys = new Set([
296
+ "accessToken",
297
+ "access_token",
298
+ "jwt",
299
+ "token",
300
+ "refreshToken",
301
+ "refresh_token",
302
+ "firebaseToken",
303
+ "firebase_token",
304
+ "idToken",
305
+ "id_token",
306
+ "code",
307
+ "authCode"
308
+ ]);
309
+
310
+ return new Promise((resolve, reject) => {
311
+ let callbackResolved = false;
312
+ let callbackSettled = false;
313
+ let startupSettled = false;
314
+ let callbackResolve: (query: string) => void = () => {};
315
+ let callbackReject: (error: Error) => void = () => {};
316
+ const callback = new Promise<string>((res, rej) => {
317
+ callbackResolve = res;
318
+ callbackReject = rej;
319
+ });
320
+
321
+ const server = http.createServer((req, res) => {
322
+ const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1");
323
+ const hasToken = Array.from(reqUrl.searchParams.keys()).some((k) => tokenKeys.has(k));
324
+
325
+ if (hasToken) {
326
+ callbackResolved = true;
327
+ clearTimeout(timeout);
328
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
329
+ res.end("<html><body><h3>Authentication successful.</h3>You can close this tab.</body></html>");
330
+ const query = reqUrl.search;
331
+ server.close(() => {
332
+ if (!callbackSettled) {
333
+ callbackSettled = true;
334
+ callbackResolve(query);
335
+ }
336
+ });
337
+ return;
338
+ }
339
+
340
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
341
+ res.end(`<html><body><h3>Completing authentication...</h3>
342
+ <script>
343
+ const hash = window.location.hash ? window.location.hash.substring(1) : "";
344
+ if (hash && !window.location.search) {
345
+ window.location.replace(window.location.pathname + "?" + hash);
346
+ }
347
+ </script>
348
+ You can close this tab.</body></html>`);
349
+ });
350
+
351
+ const timeout = setTimeout(() => {
352
+ if (!callbackResolved) {
353
+ server.close(() => {
354
+ if (!callbackSettled) {
355
+ callbackSettled = true;
356
+ callbackReject(new Error("Authentication timed out or callback not received."));
357
+ }
358
+ });
359
+ }
360
+ }, timeoutSec * 1000);
361
+
362
+ let redirectUrl = "";
363
+ server.listen(0, "127.0.0.1", () => {
364
+ const address = server.address();
365
+ if (!address || typeof address === "string") {
366
+ clearTimeout(timeout);
367
+ server.close(() => {
368
+ if (!startupSettled) {
369
+ startupSettled = true;
370
+ reject(new Error("Unable to acquire callback listener port."));
371
+ }
372
+ });
373
+ return;
374
+ }
375
+ redirectUrl = `http://127.0.0.1:${address.port}/callback`;
376
+ if (!startupSettled) {
377
+ startupSettled = true;
378
+ resolve({ redirectUrl, callback });
379
+ }
380
+ });
381
+ });
382
+ }
383
+
384
+ export async function login(cfg: EffectiveConfig): Promise<string> {
385
+ if (!cfg.consoleBaseUrl) {
386
+ throw new Error(`No console URL configured for ${cfg.product}/${cfg.env}.`);
387
+ }
388
+
389
+ const listener = await startAuthListener(180);
390
+ const redirectUrl = listener.redirectUrl;
391
+
392
+ const loginUrl = new URL(`${cfg.consoleBaseUrl.replace(/\/$/, "")}/admin/sign-in`);
393
+ loginUrl.searchParams.set("redirect_url", redirectUrl);
394
+ loginUrl.searchParams.set("redirectUrl", redirectUrl);
395
+ loginUrl.searchParams.set("redirect_uri", redirectUrl);
396
+
397
+ process.stdout.write("Opening browser for authentication...\n");
398
+ process.stdout.write(`Login URL: ${loginUrl.toString()}\n`);
399
+ if (!openBrowser(loginUrl.toString())) {
400
+ process.stdout.write("Open this URL in your browser to continue:\n");
401
+ process.stdout.write(`${loginUrl.toString()}\n`);
402
+ }
403
+
404
+ const query = await listener.callback;
405
+ const parsed = parseCallbackQuery(query);
406
+
407
+ if (parsed.code) {
408
+ throw new Error("Authentication callback returned code-based response, exchange not implemented.");
409
+ }
410
+
411
+ let tokenResp: TokenResponse | undefined;
412
+ if (parsed.firebaseToken) {
413
+ tokenResp = await authExchangeFirebaseToken(cfg, parsed.firebaseToken);
414
+ } else if (parsed.accessToken) {
415
+ tokenResp = {
416
+ accessToken: parsed.accessToken,
417
+ refreshToken: parsed.refreshToken,
418
+ accessTokenExpiresInSec: 3600,
419
+ refreshTokenExpiresInSec: parsed.refreshToken ? 2_592_000 : 0
420
+ };
421
+ }
422
+
423
+ if (!tokenResp?.accessToken) {
424
+ throw new Error("Authentication callback did not include usable token data.");
425
+ }
426
+
427
+ const email = parsed.email ?? extractEmailFromToken(tokenResp.accessToken);
428
+ storeTokens(cfg, email, tokenResp);
429
+ return email;
430
+ }
431
+
432
+ async function validateAccessToken(cfg: EffectiveConfig, accessToken: string): Promise<boolean> {
433
+ const url = new URL(`${cfg.baseUrl.replace(/\/$/, "")}/shell`);
434
+ url.searchParams.set("cmd", "help");
435
+
436
+ const response = await fetch(url, {
437
+ method: "GET",
438
+ headers: {
439
+ "X-edge-state": authStateHeader(cfg),
440
+ Authorization: `Bearer ${accessToken}`
441
+ }
442
+ });
443
+ return response.status === 200 || response.status === 204;
444
+ }
445
+
446
+ async function refreshDefaultToken(cfg: EffectiveConfig): Promise<string | undefined> {
447
+ const db = readDb();
448
+ const scope = getScope(db, cfg);
449
+ const email = scope.default;
450
+ if (!email) {
451
+ return undefined;
452
+ }
453
+ const account = scope.accounts[email];
454
+ if (!account?.refreshToken) {
455
+ return undefined;
456
+ }
457
+
458
+ const refreshed = await authRefreshToken(cfg, account.refreshToken);
459
+ const merged: TokenResponse = {
460
+ accessToken: refreshed.accessToken,
461
+ refreshToken: refreshed.refreshToken || account.refreshToken,
462
+ accessTokenExpiresInSec: refreshed.accessTokenExpiresInSec,
463
+ refreshTokenExpiresInSec: refreshed.refreshTokenExpiresInSec
464
+ };
465
+ storeTokens(cfg, email, merged);
466
+ return merged.accessToken;
467
+ }
468
+
469
+ export async function resolveAccessToken(cfg: EffectiveConfig, forceRefresh = false): Promise<string> {
470
+ const override = getAuthHeaderFromOverride(cfg.auth);
471
+ if (override) {
472
+ throw new Error("Cannot derive JWT token when --auth basic credentials are used.");
473
+ }
474
+
475
+ const db = readDb();
476
+ const scope = getScope(db, cfg);
477
+ const email = scope.default;
478
+ if (!email) {
479
+ throw new Error(`No JWT session for ${cfg.product}/${cfg.env}. Run 'edge-cli login'.`);
480
+ }
481
+ const account = scope.accounts[email];
482
+ if (!account?.accessToken) {
483
+ throw new Error(`No access token for ${email}. Run 'edge-cli login'.`);
484
+ }
485
+
486
+ let accessToken = account.accessToken;
487
+ const expiry = account.accessExpEpoch ?? 0;
488
+ const now = nowEpoch();
489
+
490
+ if (forceRefresh && account.refreshToken) {
491
+ const refreshed = await refreshDefaultToken(cfg);
492
+ if (refreshed) {
493
+ return refreshed;
494
+ }
495
+ }
496
+
497
+ if (expiry > 0 && expiry <= now + 60 && account.refreshToken) {
498
+ try {
499
+ const refreshed = await refreshDefaultToken(cfg);
500
+ if (refreshed) {
501
+ accessToken = refreshed;
502
+ }
503
+ } catch {
504
+ // continue with existing token
505
+ }
506
+ }
507
+
508
+ if (!(await validateAccessToken(cfg, accessToken)) && account.refreshToken) {
509
+ const refreshed = await refreshDefaultToken(cfg);
510
+ if (refreshed) {
511
+ accessToken = refreshed;
512
+ }
513
+ }
514
+
515
+ return accessToken;
516
+ }
517
+
518
+ export async function resolveAuthHeader(cfg: EffectiveConfig): Promise<string> {
519
+ const override = getAuthHeaderFromOverride(cfg.auth);
520
+ if (override) {
521
+ return override;
522
+ }
523
+ const accessToken = await resolveAccessToken(cfg);
524
+ return `Bearer ${accessToken}`;
525
+ }
526
+
527
+ export { getAuthHeaderFromOverride };