@husar.ai/cli 0.4.1 → 0.4.2

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 { saveCloudAuth, saveProjectAuth, ProjectAuth, CloudAuth } from './config.js';
13
+
14
+ const DEFAULT_CALLBACK_PORT = 9876;
15
+ const CLOUD_AUTH_URL = process.env.HUSAR_AUTH_URL || 'https://husar.ai';
16
+
17
+ // ═══════════════════════════════════════════════════════════════════════════
18
+ // SECURITY UTILITIES
19
+ // ═══════════════════════════════════════════════════════════════════════════
20
+
21
+ /**
22
+ * Escape HTML special characters to prevent XSS attacks
23
+ * Used when interpolating user-provided data into HTML responses
24
+ */
25
+ function escapeHtml(str: string): string {
26
+ return str
27
+ .replace(/&/g, '&')
28
+ .replace(/</g, '&lt;')
29
+ .replace(/>/g, '&gt;')
30
+ .replace(/"/g, '&quot;')
31
+ .replace(/'/g, '&#039;');
32
+ }
33
+
34
+ /**
35
+ * Generate a cryptographically secure random state parameter for OAuth
36
+ * This prevents CSRF attacks in the OAuth flow
37
+ */
38
+ function generateState(): string {
39
+ return randomBytes(32).toString('hex');
40
+ }
41
+
42
+ // ═══════════════════════════════════════════════════════════════════════════
43
+ // TYPES
44
+ // ═══════════════════════════════════════════════════════════════════════════
45
+
46
+ export interface CloudLoginResult {
47
+ success: boolean;
48
+ email?: string;
49
+ accessToken?: string;
50
+ error?: string;
51
+ }
52
+
53
+ export interface PanelLoginResult {
54
+ success: boolean;
55
+ project?: ProjectAuth;
56
+ error?: string;
57
+ }
58
+
59
+ /** @deprecated Use CloudLoginResult or PanelLoginResult instead */
60
+ export interface LoginResult {
61
+ success: boolean;
62
+ email?: string;
63
+ project?: ProjectAuth;
64
+ error?: string;
65
+ }
66
+
67
+ // ═══════════════════════════════════════════════════════════════════════════
68
+ // CLOUD LOGIN FLOW
69
+ // ═══════════════════════════════════════════════════════════════════════════
70
+
71
+ /**
72
+ * Start cloud login flow - OAuth via husar.ai
73
+ * Returns JWT access token for cloud API operations (listing projects, etc.)
74
+ *
75
+ * Flow:
76
+ * 1. Opens browser to husar.ai/app/cli-auth
77
+ * 2. User logs in via OAuth (Google/GitHub/email)
78
+ * 3. Callback returns: access_token, user_email, state
79
+ *
80
+ * Security: Uses state parameter to prevent CSRF attacks
81
+ */
82
+ export async function startCloudLoginFlow(options?: { port?: number; timeout?: number }): Promise<CloudLoginResult> {
83
+ const port = options?.port ?? DEFAULT_CALLBACK_PORT;
84
+ const timeout = options?.timeout ?? 300000; // 5 minutes default
85
+
86
+ // Generate state parameter for CSRF protection
87
+ const expectedState = generateState();
88
+
89
+ return new Promise((resolve) => {
90
+ let resolved = false;
91
+
92
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
93
+ if (resolved) {
94
+ res.writeHead(200);
95
+ res.end('Already processed');
96
+ return;
97
+ }
98
+
99
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
100
+
101
+ if (url.pathname === '/callback') {
102
+ // Parse callback parameters (cloud auth returns JWT)
103
+ const accessToken = url.searchParams.get('access_token');
104
+ const userEmail = url.searchParams.get('user_email');
105
+ const returnedState = url.searchParams.get('state');
106
+ const error = url.searchParams.get('error');
107
+
108
+ if (error) {
109
+ resolved = true;
110
+ res.writeHead(200, { 'Content-Type': 'text/html' });
111
+ res.end(getErrorHtml(error));
112
+ server.close();
113
+ resolve({ success: false, error });
114
+ return;
115
+ }
116
+
117
+ // Validate state parameter to prevent CSRF
118
+ if (returnedState !== expectedState) {
119
+ resolved = true;
120
+ res.writeHead(400, { 'Content-Type': 'text/html' });
121
+ res.end(getErrorHtml('Invalid state parameter. This may be a CSRF attack.'));
122
+ server.close();
123
+ resolve({ success: false, error: 'State parameter mismatch - possible CSRF attack' });
124
+ return;
125
+ }
126
+
127
+ if (!accessToken) {
128
+ resolved = true;
129
+ res.writeHead(400, { 'Content-Type': 'text/html' });
130
+ res.end(getErrorHtml('Missing access token'));
131
+ server.close();
132
+ resolve({ success: false, error: 'Missing access token from cloud auth' });
133
+ return;
134
+ }
135
+
136
+ // Success - save cloud auth and close server
137
+ resolved = true;
138
+
139
+ const cloudAuth: CloudAuth = {
140
+ email: userEmail ?? undefined,
141
+ accessToken,
142
+ };
143
+
144
+ // Send success response before processing
145
+ res.writeHead(200, { 'Content-Type': 'text/html' });
146
+ res.end(getCloudSuccessHtml(userEmail ?? 'user'));
147
+
148
+ // Save cloud auth
149
+ saveCloudAuth(cloudAuth)
150
+ .then(() => {
151
+ server.close();
152
+ resolve({
153
+ success: true,
154
+ email: userEmail ?? undefined,
155
+ accessToken,
156
+ });
157
+ })
158
+ .catch((err: Error) => {
159
+ server.close();
160
+ resolve({ success: false, error: err.message });
161
+ });
162
+ } else {
163
+ res.writeHead(404);
164
+ res.end('Not found');
165
+ }
166
+ });
167
+
168
+ server.listen(port, () => {
169
+ // Server is ready, open browser (include state parameter)
170
+ const authUrl = `${CLOUD_AUTH_URL}/en/app/cli-auth?callback_port=${port}&state=${expectedState}`;
171
+ console.log(`\n🔐 Opening browser for cloud authentication...`);
172
+ console.log(` If the browser doesn't open, visit: ${authUrl}\n`);
173
+ openBrowser(authUrl);
174
+ });
175
+
176
+ server.on('error', (err) => {
177
+ if (!resolved) {
178
+ resolved = true;
179
+ resolve({ success: false, error: `Server error: ${err.message}` });
180
+ }
181
+ });
182
+
183
+ // Timeout after specified duration
184
+ setTimeout(() => {
185
+ if (!resolved) {
186
+ resolved = true;
187
+ server.close();
188
+ resolve({ success: false, error: 'Cloud login timed out. Please try again.' });
189
+ }
190
+ }, timeout);
191
+ });
192
+ }
193
+
194
+ // ═══════════════════════════════════════════════════════════════════════════
195
+ // PANEL CMS LOGIN FLOW
196
+ // ═══════════════════════════════════════════════════════════════════════════
197
+
198
+ /**
199
+ * Start panel CMS login flow - SUPERADMIN credentials
200
+ * Returns adminToken for CMS operations
201
+ *
202
+ * Flow:
203
+ * 1. Opens browser to {panelHost}/connect-device
204
+ * 2. User enters SUPERADMIN username/password (from email)
205
+ * 3. Panel verifies credentials and returns adminToken
206
+ * 4. Callback returns: admin_token, host, project_name, state
207
+ *
208
+ * Security: Uses state parameter to prevent CSRF attacks
209
+ */
210
+ export async function startPanelLoginFlow(
211
+ panelHost: string,
212
+ options?: { port?: number; timeout?: number },
213
+ ): Promise<PanelLoginResult> {
214
+ const port = options?.port ?? DEFAULT_CALLBACK_PORT;
215
+ const timeout = options?.timeout ?? 300000; // 5 minutes default
216
+
217
+ // Normalize host URL
218
+ const normalizedHost = panelHost.endsWith('/') ? panelHost.slice(0, -1) : panelHost;
219
+
220
+ // Generate state parameter for CSRF protection
221
+ const expectedState = generateState();
222
+
223
+ return new Promise((resolve) => {
224
+ let resolved = false;
225
+
226
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
227
+ if (resolved) {
228
+ res.writeHead(200);
229
+ res.end('Already processed');
230
+ return;
231
+ }
232
+
233
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
234
+
235
+ if (url.pathname === '/callback') {
236
+ // Parse callback parameters (panel returns adminToken)
237
+ const adminToken = url.searchParams.get('admin_token');
238
+ const host = url.searchParams.get('host');
239
+ const projectName = url.searchParams.get('project_name');
240
+ const returnedState = url.searchParams.get('state');
241
+ const error = url.searchParams.get('error');
242
+
243
+ if (error) {
244
+ resolved = true;
245
+ res.writeHead(200, { 'Content-Type': 'text/html' });
246
+ res.end(getErrorHtml(error));
247
+ server.close();
248
+ resolve({ success: false, error });
249
+ return;
250
+ }
251
+
252
+ // Validate state parameter to prevent CSRF
253
+ if (returnedState !== expectedState) {
254
+ resolved = true;
255
+ res.writeHead(400, { 'Content-Type': 'text/html' });
256
+ res.end(getErrorHtml('Invalid state parameter. This may be a CSRF attack.'));
257
+ server.close();
258
+ resolve({ success: false, error: 'State parameter mismatch - possible CSRF attack' });
259
+ return;
260
+ }
261
+
262
+ if (!adminToken || !host || !projectName) {
263
+ resolved = true;
264
+ res.writeHead(400, { 'Content-Type': 'text/html' });
265
+ res.end(getErrorHtml('Missing required parameters'));
266
+ server.close();
267
+ resolve({ success: false, error: 'Missing admin_token, host, or project_name from panel' });
268
+ return;
269
+ }
270
+
271
+ // Success - save project auth and close server
272
+ resolved = true;
273
+
274
+ const project: ProjectAuth = {
275
+ projectName,
276
+ host,
277
+ adminToken,
278
+ };
279
+
280
+ // Send success response before processing
281
+ res.writeHead(200, { 'Content-Type': 'text/html' });
282
+ res.end(getPanelSuccessHtml(projectName));
283
+
284
+ // Save project auth
285
+ saveProjectAuth(project)
286
+ .then(() => {
287
+ server.close();
288
+ resolve({
289
+ success: true,
290
+ project,
291
+ });
292
+ })
293
+ .catch((err) => {
294
+ server.close();
295
+ resolve({ success: false, error: err.message });
296
+ });
297
+ } else {
298
+ res.writeHead(404);
299
+ res.end('Not found');
300
+ }
301
+ });
302
+
303
+ server.listen(port, () => {
304
+ // Server is ready, open browser (include state parameter)
305
+ const authUrl = `${normalizedHost}/connect-device?callback_port=${port}&state=${expectedState}`;
306
+ console.log(`\n🔐 Opening browser for CMS authentication...`);
307
+ console.log(` You'll need your SUPERADMIN credentials (from the welcome email)`);
308
+ console.log(` If the browser doesn't open, visit: ${authUrl}\n`);
309
+ openBrowser(authUrl);
310
+ });
311
+
312
+ server.on('error', (err) => {
313
+ if (!resolved) {
314
+ resolved = true;
315
+ resolve({ success: false, error: `Server error: ${err.message}` });
316
+ }
317
+ });
318
+
319
+ // Timeout after specified duration
320
+ setTimeout(() => {
321
+ if (!resolved) {
322
+ resolved = true;
323
+ server.close();
324
+ resolve({ success: false, error: 'Panel login timed out. Please try again.' });
325
+ }
326
+ }, timeout);
327
+ });
328
+ }
329
+
330
+ // ═══════════════════════════════════════════════════════════════════════════
331
+ // LEGACY COMBINED LOGIN FLOW (for backward compatibility)
332
+ // ═══════════════════════════════════════════════════════════════════════════
333
+
334
+ /**
335
+ * @deprecated Use startCloudLoginFlow() or startPanelLoginFlow() instead
336
+ *
337
+ * Start local callback server and initiate browser login flow
338
+ * This is the old combined flow that opens husar.ai/app/cli-auth
339
+ * and expects both project selection AND admin token generation
340
+ */
341
+ export async function startLoginFlow(options?: { port?: number; timeout?: number }): Promise<LoginResult> {
342
+ const port = options?.port ?? DEFAULT_CALLBACK_PORT;
343
+ const timeout = options?.timeout ?? 300000; // 5 minutes default
344
+
345
+ return new Promise((resolve) => {
346
+ let resolved = false;
347
+
348
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
349
+ if (resolved) {
350
+ res.writeHead(200);
351
+ res.end('Already processed');
352
+ return;
353
+ }
354
+
355
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
356
+
357
+ if (url.pathname === '/callback') {
358
+ // Parse callback parameters
359
+ const host = url.searchParams.get('host');
360
+ const adminToken = url.searchParams.get('admin_token');
361
+ const projectName = url.searchParams.get('project_name');
362
+ const userEmail = url.searchParams.get('user_email');
363
+ const error = url.searchParams.get('error');
364
+
365
+ if (error) {
366
+ resolved = true;
367
+ res.writeHead(200, { 'Content-Type': 'text/html' });
368
+ res.end(getErrorHtml(error));
369
+ server.close();
370
+ resolve({ success: false, error });
371
+ return;
372
+ }
373
+
374
+ if (!host || !adminToken || !projectName) {
375
+ resolved = true;
376
+ res.writeHead(400, { 'Content-Type': 'text/html' });
377
+ res.end(getErrorHtml('Missing required parameters'));
378
+ server.close();
379
+ resolve({ success: false, error: 'Missing required parameters' });
380
+ return;
381
+ }
382
+
383
+ // Success - save credentials and close server
384
+ resolved = true;
385
+
386
+ const project: ProjectAuth = {
387
+ projectName,
388
+ host,
389
+ adminToken,
390
+ };
391
+
392
+ // Send success response before processing
393
+ res.writeHead(200, { 'Content-Type': 'text/html' });
394
+ res.end(getPanelSuccessHtml(projectName, userEmail ?? 'user'));
395
+
396
+ // Save credentials
397
+ Promise.all([
398
+ userEmail ? saveCloudAuth({ email: userEmail, accessToken: adminToken }) : Promise.resolve(),
399
+ saveProjectAuth(project),
400
+ ])
401
+ .then(() => {
402
+ server.close();
403
+ resolve({
404
+ success: true,
405
+ email: userEmail ?? undefined,
406
+ project,
407
+ });
408
+ })
409
+ .catch((err) => {
410
+ server.close();
411
+ resolve({ success: false, error: err.message });
412
+ });
413
+ } else {
414
+ res.writeHead(404);
415
+ res.end('Not found');
416
+ }
417
+ });
418
+
419
+ server.listen(port, () => {
420
+ // Server is ready, open browser
421
+ const authUrl = `${CLOUD_AUTH_URL}/en/app/cli-auth?callback_port=${port}`;
422
+ console.log(`\n🔐 Opening browser for authentication...`);
423
+ console.log(` If the browser doesn't open, visit: ${authUrl}\n`);
424
+ openBrowser(authUrl);
425
+ });
426
+
427
+ server.on('error', (err) => {
428
+ if (!resolved) {
429
+ resolved = true;
430
+ resolve({ success: false, error: `Server error: ${err.message}` });
431
+ }
432
+ });
433
+
434
+ // Timeout after specified duration
435
+ setTimeout(() => {
436
+ if (!resolved) {
437
+ resolved = true;
438
+ server.close();
439
+ resolve({ success: false, error: 'Login timed out. Please try again.' });
440
+ }
441
+ }, timeout);
442
+ });
443
+ }
444
+
445
+ // ═══════════════════════════════════════════════════════════════════════════
446
+ // HELPERS
447
+ // ═══════════════════════════════════════════════════════════════════════════
448
+
449
+ /**
450
+ * Open URL in default browser
451
+ */
452
+ function openBrowser(url: string): void {
453
+ const { exec } = require('node:child_process');
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
+ }