@husar.ai/cli 0.4.1 → 0.4.3

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,631 @@
1
+ /**
2
+ * Login flows for Husar CLI
3
+ *
4
+ * Two separate authentication flows:
5
+ * 1. Cloud login - OAuth via husar.ai, returns JWT for listing projects
6
+ * 2. Panel CMS login - SUPERADMIN credentials, returns adminToken for CMS operations
7
+ */
8
+
9
+ import { createServer, IncomingMessage, ServerResponse } from 'node:http';
10
+ import { URL } from 'node:url';
11
+ import { randomBytes } from 'node:crypto';
12
+ import { exec } from 'node:child_process';
13
+ import { saveCloudAuth, saveProjectAuth, ProjectAuth, CloudAuth } from './config.js';
14
+
15
+ const DEFAULT_CALLBACK_PORT = 9876;
16
+ const CLOUD_AUTH_URL = process.env.HUSAR_AUTH_URL || 'https://husar.ai';
17
+
18
+ // ═══════════════════════════════════════════════════════════════════════════
19
+ // SECURITY UTILITIES
20
+ // ═══════════════════════════════════════════════════════════════════════════
21
+
22
+ /**
23
+ * Escape HTML special characters to prevent XSS attacks
24
+ * Used when interpolating user-provided data into HTML responses
25
+ */
26
+ function escapeHtml(str: string): string {
27
+ return str
28
+ .replace(/&/g, '&')
29
+ .replace(/</g, '&lt;')
30
+ .replace(/>/g, '&gt;')
31
+ .replace(/"/g, '&quot;')
32
+ .replace(/'/g, '&#039;');
33
+ }
34
+
35
+ /**
36
+ * Generate a cryptographically secure random state parameter for OAuth
37
+ * This prevents CSRF attacks in the OAuth flow
38
+ */
39
+ function generateState(): string {
40
+ return randomBytes(32).toString('hex');
41
+ }
42
+
43
+ // ═══════════════════════════════════════════════════════════════════════════
44
+ // TYPES
45
+ // ═══════════════════════════════════════════════════════════════════════════
46
+
47
+ export interface CloudLoginResult {
48
+ success: boolean;
49
+ email?: string;
50
+ accessToken?: string;
51
+ error?: string;
52
+ }
53
+
54
+ export interface PanelLoginResult {
55
+ success: boolean;
56
+ project?: ProjectAuth;
57
+ error?: string;
58
+ }
59
+
60
+ /** @deprecated Use CloudLoginResult or PanelLoginResult instead */
61
+ export interface LoginResult {
62
+ success: boolean;
63
+ email?: string;
64
+ project?: ProjectAuth;
65
+ error?: string;
66
+ }
67
+
68
+ // ═══════════════════════════════════════════════════════════════════════════
69
+ // CLOUD LOGIN FLOW
70
+ // ═══════════════════════════════════════════════════════════════════════════
71
+
72
+ /**
73
+ * Start cloud login flow - OAuth via husar.ai
74
+ * Returns JWT access token for cloud API operations (listing projects, etc.)
75
+ *
76
+ * Flow:
77
+ * 1. Opens browser to husar.ai/app/cli-auth
78
+ * 2. User logs in via OAuth (Google/GitHub/email)
79
+ * 3. Callback returns: access_token, user_email, state
80
+ *
81
+ * Security: Uses state parameter to prevent CSRF attacks
82
+ */
83
+ export async function startCloudLoginFlow(options?: { port?: number; timeout?: number }): Promise<CloudLoginResult> {
84
+ const port = options?.port ?? DEFAULT_CALLBACK_PORT;
85
+ const timeout = options?.timeout ?? 300000; // 5 minutes default
86
+
87
+ // Generate state parameter for CSRF protection
88
+ const expectedState = generateState();
89
+
90
+ return new Promise((resolve) => {
91
+ let resolved = false;
92
+
93
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
94
+ if (resolved) {
95
+ res.writeHead(200);
96
+ res.end('Already processed');
97
+ return;
98
+ }
99
+
100
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
101
+
102
+ if (url.pathname === '/callback') {
103
+ // Parse callback parameters (cloud auth returns JWT)
104
+ const accessToken = url.searchParams.get('access_token');
105
+ const userEmail = url.searchParams.get('user_email');
106
+ const returnedState = url.searchParams.get('state');
107
+ const error = url.searchParams.get('error');
108
+
109
+ if (error) {
110
+ resolved = true;
111
+ res.writeHead(200, { 'Content-Type': 'text/html' });
112
+ res.end(getErrorHtml(error));
113
+ server.close();
114
+ resolve({ success: false, error });
115
+ return;
116
+ }
117
+
118
+ // Validate state parameter to prevent CSRF
119
+ if (returnedState !== expectedState) {
120
+ resolved = true;
121
+ res.writeHead(400, { 'Content-Type': 'text/html' });
122
+ res.end(getErrorHtml('Invalid state parameter. This may be a CSRF attack.'));
123
+ server.close();
124
+ resolve({ success: false, error: 'State parameter mismatch - possible CSRF attack' });
125
+ return;
126
+ }
127
+
128
+ if (!accessToken) {
129
+ resolved = true;
130
+ res.writeHead(400, { 'Content-Type': 'text/html' });
131
+ res.end(getErrorHtml('Missing access token'));
132
+ server.close();
133
+ resolve({ success: false, error: 'Missing access token from cloud auth' });
134
+ return;
135
+ }
136
+
137
+ // Success - save cloud auth and close server
138
+ resolved = true;
139
+
140
+ const cloudAuth: CloudAuth = {
141
+ email: userEmail ?? undefined,
142
+ accessToken,
143
+ };
144
+
145
+ // Send success response before processing
146
+ res.writeHead(200, { 'Content-Type': 'text/html' });
147
+ res.end(getCloudSuccessHtml(userEmail ?? 'user'));
148
+
149
+ // Save cloud auth
150
+ saveCloudAuth(cloudAuth)
151
+ .then(() => {
152
+ server.close();
153
+ resolve({
154
+ success: true,
155
+ email: userEmail ?? undefined,
156
+ accessToken,
157
+ });
158
+ })
159
+ .catch((err: Error) => {
160
+ server.close();
161
+ resolve({ success: false, error: err.message });
162
+ });
163
+ } else {
164
+ res.writeHead(404);
165
+ res.end('Not found');
166
+ }
167
+ });
168
+
169
+ server.listen(port, () => {
170
+ // Server is ready, open browser (include state parameter)
171
+ const authUrl = `${CLOUD_AUTH_URL}/en/app/cli-auth?callback_port=${port}&state=${expectedState}`;
172
+ console.log(`\n🔐 Opening browser for cloud authentication...`);
173
+ console.log(` If the browser doesn't open, visit: ${authUrl}\n`);
174
+ openBrowser(authUrl);
175
+ });
176
+
177
+ server.on('error', (err) => {
178
+ if (!resolved) {
179
+ resolved = true;
180
+ resolve({ success: false, error: `Server error: ${err.message}` });
181
+ }
182
+ });
183
+
184
+ // Timeout after specified duration
185
+ setTimeout(() => {
186
+ if (!resolved) {
187
+ resolved = true;
188
+ server.close();
189
+ resolve({ success: false, error: 'Cloud login timed out. Please try again.' });
190
+ }
191
+ }, timeout);
192
+ });
193
+ }
194
+
195
+ // ═══════════════════════════════════════════════════════════════════════════
196
+ // PANEL CMS LOGIN FLOW
197
+ // ═══════════════════════════════════════════════════════════════════════════
198
+
199
+ /**
200
+ * Start panel CMS login flow - SUPERADMIN credentials
201
+ * Returns adminToken for CMS operations
202
+ *
203
+ * Flow:
204
+ * 1. Opens browser to {panelHost}/connect-device
205
+ * 2. User enters SUPERADMIN username/password (from email)
206
+ * 3. Panel verifies credentials and returns adminToken
207
+ * 4. Callback returns: admin_token, host, project_name, state
208
+ *
209
+ * Security: Uses state parameter to prevent CSRF attacks
210
+ */
211
+ export async function startPanelLoginFlow(
212
+ panelHost: string,
213
+ options?: { port?: number; timeout?: number },
214
+ ): Promise<PanelLoginResult> {
215
+ const port = options?.port ?? DEFAULT_CALLBACK_PORT;
216
+ const timeout = options?.timeout ?? 300000; // 5 minutes default
217
+
218
+ // Normalize host URL
219
+ const normalizedHost = panelHost.endsWith('/') ? panelHost.slice(0, -1) : panelHost;
220
+
221
+ // Generate state parameter for CSRF protection
222
+ const expectedState = generateState();
223
+
224
+ return new Promise((resolve) => {
225
+ let resolved = false;
226
+
227
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
228
+ if (resolved) {
229
+ res.writeHead(200);
230
+ res.end('Already processed');
231
+ return;
232
+ }
233
+
234
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
235
+
236
+ if (url.pathname === '/callback') {
237
+ // Parse callback parameters (panel returns adminToken)
238
+ const adminToken = url.searchParams.get('admin_token');
239
+ const host = url.searchParams.get('host');
240
+ const projectName = url.searchParams.get('project_name');
241
+ const returnedState = url.searchParams.get('state');
242
+ const error = url.searchParams.get('error');
243
+
244
+ if (error) {
245
+ resolved = true;
246
+ res.writeHead(200, { 'Content-Type': 'text/html' });
247
+ res.end(getErrorHtml(error));
248
+ server.close();
249
+ resolve({ success: false, error });
250
+ return;
251
+ }
252
+
253
+ // Validate state parameter to prevent CSRF
254
+ if (returnedState !== expectedState) {
255
+ resolved = true;
256
+ res.writeHead(400, { 'Content-Type': 'text/html' });
257
+ res.end(getErrorHtml('Invalid state parameter. This may be a CSRF attack.'));
258
+ server.close();
259
+ resolve({ success: false, error: 'State parameter mismatch - possible CSRF attack' });
260
+ return;
261
+ }
262
+
263
+ if (!adminToken || !host || !projectName) {
264
+ resolved = true;
265
+ res.writeHead(400, { 'Content-Type': 'text/html' });
266
+ res.end(getErrorHtml('Missing required parameters'));
267
+ server.close();
268
+ resolve({ success: false, error: 'Missing admin_token, host, or project_name from panel' });
269
+ return;
270
+ }
271
+
272
+ // Success - save project auth and close server
273
+ resolved = true;
274
+
275
+ const project: ProjectAuth = {
276
+ projectName,
277
+ host,
278
+ adminToken,
279
+ };
280
+
281
+ // Send success response before processing
282
+ res.writeHead(200, { 'Content-Type': 'text/html' });
283
+ res.end(getPanelSuccessHtml(projectName));
284
+
285
+ // Save project auth
286
+ saveProjectAuth(project)
287
+ .then(() => {
288
+ server.close();
289
+ resolve({
290
+ success: true,
291
+ project,
292
+ });
293
+ })
294
+ .catch((err: Error) => {
295
+ server.close();
296
+ resolve({ success: false, error: err.message });
297
+ });
298
+ } else {
299
+ res.writeHead(404);
300
+ res.end('Not found');
301
+ }
302
+ });
303
+
304
+ server.listen(port, () => {
305
+ // Server is ready, open browser (include state parameter)
306
+ const authUrl = `${normalizedHost}/connect-device?callback_port=${port}&state=${expectedState}`;
307
+ console.log(`\n🔐 Opening browser for CMS authentication...`);
308
+ console.log(` You'll need your SUPERADMIN credentials (from the welcome email)`);
309
+ console.log(` If the browser doesn't open, visit: ${authUrl}\n`);
310
+ openBrowser(authUrl);
311
+ });
312
+
313
+ server.on('error', (err) => {
314
+ if (!resolved) {
315
+ resolved = true;
316
+ resolve({ success: false, error: `Server error: ${err.message}` });
317
+ }
318
+ });
319
+
320
+ // Timeout after specified duration
321
+ setTimeout(() => {
322
+ if (!resolved) {
323
+ resolved = true;
324
+ server.close();
325
+ resolve({ success: false, error: 'Panel login timed out. Please try again.' });
326
+ }
327
+ }, timeout);
328
+ });
329
+ }
330
+
331
+ // ═══════════════════════════════════════════════════════════════════════════
332
+ // LEGACY COMBINED LOGIN FLOW (for backward compatibility)
333
+ // ═══════════════════════════════════════════════════════════════════════════
334
+
335
+ /**
336
+ * @deprecated Use startCloudLoginFlow() or startPanelLoginFlow() instead
337
+ *
338
+ * Start local callback server and initiate browser login flow
339
+ * This is the old combined flow that opens husar.ai/app/cli-auth
340
+ * and expects both project selection AND admin token generation
341
+ */
342
+ export async function startLoginFlow(options?: { port?: number; timeout?: number }): Promise<LoginResult> {
343
+ const port = options?.port ?? DEFAULT_CALLBACK_PORT;
344
+ const timeout = options?.timeout ?? 300000; // 5 minutes default
345
+
346
+ return new Promise((resolve) => {
347
+ let resolved = false;
348
+
349
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
350
+ if (resolved) {
351
+ res.writeHead(200);
352
+ res.end('Already processed');
353
+ return;
354
+ }
355
+
356
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
357
+
358
+ if (url.pathname === '/callback') {
359
+ // Parse callback parameters
360
+ const host = url.searchParams.get('host');
361
+ const adminToken = url.searchParams.get('admin_token');
362
+ const projectName = url.searchParams.get('project_name');
363
+ const userEmail = url.searchParams.get('user_email');
364
+ const error = url.searchParams.get('error');
365
+
366
+ if (error) {
367
+ resolved = true;
368
+ res.writeHead(200, { 'Content-Type': 'text/html' });
369
+ res.end(getErrorHtml(error));
370
+ server.close();
371
+ resolve({ success: false, error });
372
+ return;
373
+ }
374
+
375
+ if (!host || !adminToken || !projectName) {
376
+ resolved = true;
377
+ res.writeHead(400, { 'Content-Type': 'text/html' });
378
+ res.end(getErrorHtml('Missing required parameters'));
379
+ server.close();
380
+ resolve({ success: false, error: 'Missing required parameters' });
381
+ return;
382
+ }
383
+
384
+ // Success - save credentials and close server
385
+ resolved = true;
386
+
387
+ const project: ProjectAuth = {
388
+ projectName,
389
+ host,
390
+ adminToken,
391
+ };
392
+
393
+ // Send success response before processing
394
+ res.writeHead(200, { 'Content-Type': 'text/html' });
395
+ res.end(getPanelSuccessHtml(projectName, userEmail ?? 'user'));
396
+
397
+ // Save credentials
398
+ Promise.all([
399
+ userEmail ? saveCloudAuth({ email: userEmail, accessToken: adminToken }) : Promise.resolve(),
400
+ saveProjectAuth(project),
401
+ ])
402
+ .then(() => {
403
+ server.close();
404
+ resolve({
405
+ success: true,
406
+ email: userEmail ?? undefined,
407
+ project,
408
+ });
409
+ })
410
+ .catch((err: Error) => {
411
+ server.close();
412
+ resolve({ success: false, error: err.message });
413
+ });
414
+ } else {
415
+ res.writeHead(404);
416
+ res.end('Not found');
417
+ }
418
+ });
419
+
420
+ server.listen(port, () => {
421
+ // Server is ready, open browser
422
+ const authUrl = `${CLOUD_AUTH_URL}/en/app/cli-auth?callback_port=${port}`;
423
+ console.log(`\n🔐 Opening browser for authentication...`);
424
+ console.log(` If the browser doesn't open, visit: ${authUrl}\n`);
425
+ openBrowser(authUrl);
426
+ });
427
+
428
+ server.on('error', (err) => {
429
+ if (!resolved) {
430
+ resolved = true;
431
+ resolve({ success: false, error: `Server error: ${err.message}` });
432
+ }
433
+ });
434
+
435
+ // Timeout after specified duration
436
+ setTimeout(() => {
437
+ if (!resolved) {
438
+ resolved = true;
439
+ server.close();
440
+ resolve({ success: false, error: 'Login timed out. Please try again.' });
441
+ }
442
+ }, timeout);
443
+ });
444
+ }
445
+
446
+ // ═══════════════════════════════════════════════════════════════════════════
447
+ // HELPERS
448
+ // ═══════════════════════════════════════════════════════════════════════════
449
+
450
+ /**
451
+ * Open URL in default browser
452
+ */
453
+ function openBrowser(url: string): void {
454
+ const platform = process.platform;
455
+
456
+ let command: string;
457
+ if (platform === 'darwin') {
458
+ command = `open "${url}"`;
459
+ } else if (platform === 'win32') {
460
+ command = `start "" "${url}"`;
461
+ } else {
462
+ // Linux and others
463
+ command = `xdg-open "${url}"`;
464
+ }
465
+
466
+ exec(command, (err: Error | null) => {
467
+ if (err) {
468
+ console.error('Could not open browser automatically.');
469
+ }
470
+ });
471
+ }
472
+
473
+ /**
474
+ * Generate cloud auth success HTML page
475
+ */
476
+ function getCloudSuccessHtml(email: string): string {
477
+ return `
478
+ <!DOCTYPE html>
479
+ <html>
480
+ <head>
481
+ <meta charset="utf-8">
482
+ <title>Husar CLI - Cloud Login Successful</title>
483
+ <style>
484
+ body {
485
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
486
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
487
+ color: white;
488
+ min-height: 100vh;
489
+ display: flex;
490
+ align-items: center;
491
+ justify-content: center;
492
+ margin: 0;
493
+ }
494
+ .container {
495
+ text-align: center;
496
+ padding: 40px;
497
+ background: rgba(255,255,255,0.05);
498
+ border-radius: 16px;
499
+ border: 1px solid rgba(255,255,255,0.1);
500
+ }
501
+ .success-icon {
502
+ font-size: 64px;
503
+ margin-bottom: 20px;
504
+ }
505
+ h1 { margin: 0 0 10px; }
506
+ p { color: #a0a0a0; margin: 5px 0; }
507
+ .email { color: #60a5fa; font-weight: 600; }
508
+ .close-note {
509
+ margin-top: 30px;
510
+ font-size: 14px;
511
+ color: #666;
512
+ }
513
+ </style>
514
+ </head>
515
+ <body>
516
+ <div class="container">
517
+ <div class="success-icon">✅</div>
518
+ <h1>Cloud Login Successful!</h1>
519
+ <p>Logged in as <span class="email">${escapeHtml(email)}</span></p>
520
+ <p class="close-note">You can close this window and return to the terminal.</p>
521
+ </div>
522
+ </body>
523
+ </html>
524
+ `.trim();
525
+ }
526
+
527
+ /**
528
+ * Generate panel auth success HTML page
529
+ */
530
+ function getPanelSuccessHtml(projectName: string, email?: string): string {
531
+ const emailLine = email ? `<p>Logged in as <strong>${escapeHtml(email)}</strong></p>` : '';
532
+
533
+ return `
534
+ <!DOCTYPE html>
535
+ <html>
536
+ <head>
537
+ <meta charset="utf-8">
538
+ <title>Husar CLI - CMS Connected</title>
539
+ <style>
540
+ body {
541
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
542
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
543
+ color: white;
544
+ min-height: 100vh;
545
+ display: flex;
546
+ align-items: center;
547
+ justify-content: center;
548
+ margin: 0;
549
+ }
550
+ .container {
551
+ text-align: center;
552
+ padding: 40px;
553
+ background: rgba(255,255,255,0.05);
554
+ border-radius: 16px;
555
+ border: 1px solid rgba(255,255,255,0.1);
556
+ }
557
+ .success-icon {
558
+ font-size: 64px;
559
+ margin-bottom: 20px;
560
+ }
561
+ h1 { margin: 0 0 10px; }
562
+ p { color: #a0a0a0; margin: 5px 0; }
563
+ .project { color: #60a5fa; font-weight: 600; }
564
+ .close-note {
565
+ margin-top: 30px;
566
+ font-size: 14px;
567
+ color: #666;
568
+ }
569
+ </style>
570
+ </head>
571
+ <body>
572
+ <div class="container">
573
+ <div class="success-icon">✅</div>
574
+ <h1>CMS Connected!</h1>
575
+ ${emailLine}
576
+ <p>Connected to project <span class="project">${escapeHtml(projectName)}</span></p>
577
+ <p class="close-note">You can close this window and return to the terminal.</p>
578
+ </div>
579
+ </body>
580
+ </html>
581
+ `.trim();
582
+ }
583
+
584
+ /**
585
+ * Generate error HTML page
586
+ */
587
+ function getErrorHtml(error: string): string {
588
+ return `
589
+ <!DOCTYPE html>
590
+ <html>
591
+ <head>
592
+ <meta charset="utf-8">
593
+ <title>Husar CLI - Error</title>
594
+ <style>
595
+ body {
596
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
597
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
598
+ color: white;
599
+ min-height: 100vh;
600
+ display: flex;
601
+ align-items: center;
602
+ justify-content: center;
603
+ margin: 0;
604
+ }
605
+ .container {
606
+ text-align: center;
607
+ padding: 40px;
608
+ background: rgba(255,255,255,0.05);
609
+ border-radius: 16px;
610
+ border: 1px solid rgba(239,68,68,0.3);
611
+ }
612
+ .error-icon {
613
+ font-size: 64px;
614
+ margin-bottom: 20px;
615
+ }
616
+ h1 { margin: 0 0 10px; color: #ef4444; }
617
+ p { color: #a0a0a0; margin: 5px 0; }
618
+ .error-msg { color: #fca5a5; }
619
+ </style>
620
+ </head>
621
+ <body>
622
+ <div class="container">
623
+ <div class="error-icon">❌</div>
624
+ <h1>Authorization Failed</h1>
625
+ <p class="error-msg">${escapeHtml(error)}</p>
626
+ <p>Please try again or contact support.</p>
627
+ </div>
628
+ </body>
629
+ </html>
630
+ `.trim();
631
+ }