@mcp-abap-adt/auth-providers 1.1.0 → 1.2.0

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.
@@ -47,7 +47,7 @@ const http = __importStar(require("node:http"));
47
47
  const net = __importStar(require("node:net"));
48
48
  const readline = __importStar(require("node:readline"));
49
49
  const axios_1 = __importDefault(require("axios"));
50
- const express_1 = __importDefault(require("express"));
50
+ const callbackServer_1 = require("./callbackServer");
51
51
  const BROWSER_MAP = {
52
52
  chrome: 'chrome',
53
53
  edge: 'msedge',
@@ -168,9 +168,116 @@ function isPortAvailable(port) {
168
168
  * @param port Port for OAuth callback server (default: 3001)
169
169
  * @returns Promise that resolves to tokens
170
170
  * @internal - Internal function, not exported from package
171
+ */ /**
172
+ * Open the authorization URL, or tell the user how to do it.
173
+ *
174
+ * Never awaited on the critical path by the caller: a launcher that hangs must
175
+ * not delay the login timeout or the release of the port. A launcher that fails
176
+ * is reported through the scope's `fail`, which is just another way for the
177
+ * scope to end.
178
+ */
179
+ async function launchBrowser(authorizationUrl, browser, port, announce, log) {
180
+ const browserApp = BROWSER_MAP[browser];
181
+ // 'none' / 'headless': show the URL and wait. For SSH and remote sessions.
182
+ if (browser === 'none' || browser === 'headless') {
183
+ announce('🔗 Open this URL in your browser to authenticate:');
184
+ announce(` ${authorizationUrl}`);
185
+ announce(` Waiting for callback on http://localhost:${port}/callback ...`);
186
+ announce(' If your browser is on another machine, after login copy the ' +
187
+ '`code` from the address bar and paste it at ' +
188
+ `http://<this-host>:${port}/ — or paste it here and press Enter.`);
189
+ return;
190
+ }
191
+ if (browser === 'auto') {
192
+ log?.info('🌐 Attempting to open browser for authentication...');
193
+ try {
194
+ const openModule = await Promise.resolve().then(() => __importStar(require('open')));
195
+ await openModule.default(authorizationUrl);
196
+ log?.info('✅ Browser opened successfully. Waiting for authentication...');
197
+ }
198
+ catch (error) {
199
+ const message = error instanceof Error ? error.message : String(error);
200
+ log?.warn(`⚠️ Could not open browser automatically: ${message}`);
201
+ announce('🔗 Please open this URL in your browser to authenticate:');
202
+ announce(` ${authorizationUrl}`);
203
+ announce(` Waiting for callback on http://localhost:${port}/callback ...`);
204
+ }
205
+ return;
206
+ }
207
+ if (browserApp === null)
208
+ return;
209
+ // On Linux, ensure DISPLAY is set for X11 applications.
210
+ if (process.platform === 'linux' &&
211
+ !process.env.DISPLAY &&
212
+ !process.env.WAYLAND_DISPLAY) {
213
+ process.env.DISPLAY = ':0';
214
+ log?.debug('DISPLAY not set, using fallback DISPLAY=:0');
215
+ }
216
+ let open = null;
217
+ try {
218
+ const openModule = await Promise.resolve().then(() => __importStar(require('open')));
219
+ open = openModule.default;
220
+ }
221
+ catch {
222
+ open = null;
223
+ }
224
+ if (!open) {
225
+ // Fallback: shell out. Non-blocking by design.
226
+ const platform = process.platform;
227
+ let command;
228
+ if (browserApp === 'chrome') {
229
+ command =
230
+ platform === 'win32'
231
+ ? 'cmd /c start "" "chrome"'
232
+ : platform === 'darwin'
233
+ ? 'open -a "Google Chrome"'
234
+ : 'google-chrome || chromium || chromium-browser';
235
+ }
236
+ else if (browserApp === 'msedge') {
237
+ command =
238
+ platform === 'win32'
239
+ ? 'cmd /c start "" "msedge"'
240
+ : platform === 'darwin'
241
+ ? 'open -a "Microsoft Edge"'
242
+ : 'microsoft-edge || microsoft-edge-stable';
243
+ }
244
+ else if (browserApp === 'firefox') {
245
+ command =
246
+ platform === 'win32'
247
+ ? 'cmd /c start "" "firefox"'
248
+ : platform === 'darwin'
249
+ ? 'open -a Firefox'
250
+ : 'firefox || firefox-esr';
251
+ }
252
+ else {
253
+ command =
254
+ platform === 'win32'
255
+ ? 'cmd /c start ""'
256
+ : platform === 'darwin'
257
+ ? 'open'
258
+ : 'xdg-open';
259
+ }
260
+ child_process.exec(`${command} "${authorizationUrl}"`, (error) => {
261
+ if (error) {
262
+ log?.error(`❌ Failed to open browser: ${error.message}. Please open manually: ${authorizationUrl}`, { error: error.message, url: authorizationUrl });
263
+ }
264
+ });
265
+ return;
266
+ }
267
+ if (browserApp)
268
+ await open(authorizationUrl, { app: { name: browserApp } });
269
+ else
270
+ await open(authorizationUrl);
271
+ }
272
+ /**
273
+ * Interactive browser login for the UAA authorization-code flow.
274
+ *
275
+ * The callback socket is owned by `withBrowserCallbackServer`: it is released
276
+ * when the scope ends, whatever ends it, and before the code is exchanged — so
277
+ * a slow UAA cannot hold the port, and a settled promise always means the port
278
+ * is free.
171
279
  */
172
- async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3001) {
173
- // Use logger if provided, otherwise null (no logging)
280
+ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3001, timeoutMs = 30 * 1000) {
174
281
  const log = logger || null;
175
282
  // Essential, user-facing prompts (the auth URL, paste instructions) must be
176
283
  // visible even when no logger is supplied. Fall back to stderr — never stdout,
@@ -181,509 +288,53 @@ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3
181
288
  else
182
289
  process.stderr.write(`${msg}\n`);
183
290
  };
184
- // Check if requested port is available, throw error if not
291
+ // Pre-check kept for its message: AuthBroker matches /already in use/i to
292
+ // distinguish a busy port from other failures.
185
293
  const portAvailable = await isPortAvailable(port);
186
294
  if (!portAvailable) {
187
295
  throw new Error(`Port ${port} is already in use. Please specify a different port or free the port.`);
188
296
  }
189
- return new Promise((originalResolve, originalReject) => {
190
- let timeoutId = null;
191
- let finishTimeoutId = null;
192
- let cleanupDone = false;
193
- let resolved = false;
194
- // Optional stdin reader for the manual paste channel (none/headless + TTY).
195
- let stdinReader = null;
196
- const stopStdin = () => {
197
- if (stdinReader) {
198
- stdinReader.close();
199
- stdinReader = null;
200
- }
201
- };
202
- const app = (0, express_1.default)();
203
- const server = http.createServer(app);
204
- // Disable keep-alive to ensure connections close immediately
205
- server.keepAliveTimeout = 0;
206
- server.headersTimeout = 0;
207
- const PORT = port;
208
- let serverInstance = null;
209
- // Cleanup function to ensure server is closed on process termination
210
- const cleanup = () => {
211
- if (cleanupDone)
212
- return;
213
- cleanupDone = true;
214
- log?.debug(`Cleaning up OAuth callback server on port ${PORT}`);
215
- if (timeoutId) {
216
- clearTimeout(timeoutId);
217
- timeoutId = null;
218
- }
219
- if (finishTimeoutId) {
220
- clearTimeout(finishTimeoutId);
221
- finishTimeoutId = null;
222
- }
223
- stopStdin();
224
- if (server) {
225
- try {
226
- if (typeof server.closeAllConnections === 'function') {
227
- server.closeAllConnections();
228
- }
229
- server.close(() => {
230
- log?.debug(`OAuth server closed during cleanup, port ${PORT} freed`);
231
- });
232
- }
233
- catch (_e) {
234
- // Ignore errors during cleanup
235
- }
236
- }
237
- };
238
- // Remove cleanup listeners to prevent memory leaks
239
- const removeCleanupListeners = () => {
240
- process.removeListener('exit', cleanup);
241
- process.removeListener('SIGTERM', cleanup);
242
- process.removeListener('SIGINT', cleanup);
243
- process.removeListener('SIGHUP', cleanup);
244
- if (process.platform === 'win32') {
245
- process.removeListener('SIGBREAK', cleanup);
246
- }
247
- };
248
- const resolve = (value) => {
249
- if (resolved)
250
- return; // Prevent double resolution
251
- resolved = true;
252
- if (timeoutId) {
253
- clearTimeout(timeoutId);
254
- timeoutId = null;
255
- }
256
- if (finishTimeoutId) {
257
- clearTimeout(finishTimeoutId);
258
- finishTimeoutId = null;
259
- }
260
- stopStdin();
261
- removeCleanupListeners();
262
- originalResolve(value);
263
- };
264
- const reject = (reason) => {
265
- if (timeoutId)
266
- clearTimeout(timeoutId);
267
- removeCleanupListeners();
268
- originalReject(reason);
269
- };
270
- // Register cleanup handlers for process termination
271
- // This ensures port is freed when Cline or other clients kill the process
272
- process.once('exit', cleanup);
273
- process.once('SIGTERM', cleanup);
274
- process.once('SIGINT', cleanup);
275
- process.once('SIGHUP', cleanup);
276
- // SIGBREAK is Windows-specific (Ctrl+Break)
277
- if (process.platform === 'win32') {
278
- process.once('SIGBREAK', cleanup);
279
- }
280
- // Use provided authorization URL or build from authConfig
281
- const authorizationUrl = authConfig.authorizationUrl ?? getJwtAuthorizationUrl(authConfig, PORT);
297
+ let stdinReader = null;
298
+ const code = await (0, callbackServer_1.withBrowserCallbackServer)({ port, timeoutMs }, async (server) => {
299
+ const authorizationUrl = authConfig.authorizationUrl ??
300
+ getJwtAuthorizationUrl(authConfig, server.port);
282
301
  log?.info(`[browserAuth] Authorization URL: ${authorizationUrl}`);
283
- log?.info(`[browserAuth] Server listening on port: ${PORT}`);
284
- // Verify port in redirect_uri matches server port
285
- const redirectUriMatch = authorizationUrl.match(/redirect_uri=([^&]+)/);
286
- if (redirectUriMatch) {
287
- const redirectUri = decodeURIComponent(redirectUriMatch[1]);
288
- const urlPortMatch = redirectUri.match(/localhost:(\d+)/);
289
- if (urlPortMatch) {
290
- const urlPort = parseInt(urlPortMatch[1], 10);
291
- if (urlPort !== PORT) {
292
- log?.warn(`[browserAuth] WARNING: Port mismatch! URL has port ${urlPort}, but server listens on ${PORT}`);
293
- }
294
- else {
295
- log?.info(`[browserAuth] Port match: URL and server both use port ${PORT}`);
296
- }
297
- }
298
- }
299
- // Success page shown in the browser after a code is exchanged for tokens.
300
- const successHtml = `<!DOCTYPE html>
301
- <html lang="en"><head><meta charset="UTF-8">
302
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
303
- <title>SAP BTP Authentication</title>
304
- <style>body{font-family:'Segoe UI',Tahoma,sans-serif;text-align:center;padding:50px 20px;background:linear-gradient(135deg,#0070f3,#00d4ff);color:#fff;min-height:100vh;display:flex;flex-direction:column;justify-content:center;align-items:center}.container{background:rgba(255,255,255,.1);border-radius:20px;padding:40px;max-width:500px}.success-icon{font-size:4rem;margin-bottom:20px;color:#4ade80}h1{font-weight:300}</style>
305
- </head><body><div class="container"><div class="success-icon">✓</div>
306
- <h1>Authentication Successful!</h1>
307
- <p>You have successfully authenticated with SAP BTP. You can close this window.</p>
308
- </div></body></html>`;
309
- // Manual paste form (GET /). Used when the automatic localhost callback
310
- // cannot reach this server (browser on another machine). Accepts a bare
311
- // code or a full redirected URL; re-renders with a message on a bad paste.
312
- const pasteFormHtml = (message) => `<!DOCTYPE html>
313
- <html lang="en"><head><meta charset="UTF-8">
314
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
315
- <title>SAP BTP Authentication — paste code</title>
316
- <style>body{font-family:'Segoe UI',Tahoma,sans-serif;text-align:center;padding:50px 20px;background:linear-gradient(135deg,#0070f3,#00d4ff);color:#fff;min-height:100vh;display:flex;flex-direction:column;justify-content:center;align-items:center}.container{background:rgba(255,255,255,.1);border-radius:20px;padding:40px;max-width:560px;width:100%}h1{font-weight:300}input{width:100%;padding:12px;border-radius:8px;border:none;font-size:1rem;box-sizing:border-box;margin:14px 0}button{padding:12px 24px;border-radius:8px;border:none;background:#fff;color:#0070f3;font-size:1rem;cursor:pointer}.msg{color:#fde68a;margin-bottom:10px}</style>
317
- </head><body><div class="container">
318
- <h1>Paste authorization code</h1>
319
- ${message ? `<p class="msg">${message}</p>` : ''}
320
- <p>After signing in, copy the <code>code</code> from your browser's address bar
321
- (or paste the whole redirected URL) and submit it here.</p>
322
- <form action="/submit" method="get">
323
- <input name="input" autofocus placeholder="code=... or http://localhost/callback?code=..." />
324
- <button type="submit">Submit</button>
325
- </form></div></body></html>`;
326
- // Resolve the outer promise once a code has been turned into tokens.
327
- const finalizeSuccess = (tokens, res) => {
328
- let serverClosing = false;
329
- const closeServerAndResolve = () => {
330
- if (serverClosing)
302
+ log?.info(`[browserAuth] Server listening on port: ${server.port}`);
303
+ const waiting = server.waitForResult();
304
+ // Not awaited: a launcher that hangs must not delay the timeout or the
305
+ // release, and one that fails ends the scope through `fail`.
306
+ void launchBrowser(authorizationUrl, browser, server.port, announce, log).catch((error) => {
307
+ const message = error instanceof Error ? error.message : String(error);
308
+ log?.error(`❌ Failed to open browser: ${message}. Please open manually: ${authorizationUrl}`, { error: message, url: authorizationUrl });
309
+ server.fail(new Error(`Browser opening failed for destination authentication. Please open manually: ${authorizationUrl}`));
310
+ });
311
+ // Manual stdin paste — only when attached to an interactive terminal.
312
+ // Under a stdio RPC transport stdin carries the protocol, so we must never
313
+ // consume it; isTTY guards that. A pasted line is handed to the same
314
+ // `/submit` route the paste form uses, so there is one way in, not two.
315
+ if ((browser === 'none' || browser === 'headless') &&
316
+ process.stdin.isTTY) {
317
+ stdinReader = readline.createInterface({ input: process.stdin });
318
+ stdinReader.on('line', (line) => {
319
+ const pasted = extractCode(line);
320
+ if (!pasted) {
321
+ process.stderr.write('Could not read an authorization code from that input. Try again.\n');
331
322
  return;
332
- serverClosing = true;
333
- if (finishTimeoutId) {
334
- clearTimeout(finishTimeoutId);
335
- finishTimeoutId = null;
336
- }
337
- if (typeof server.closeAllConnections === 'function') {
338
- server.closeAllConnections();
339
323
  }
340
- server.close(() => {
341
- log?.info(`[browserAuth] Server closed, port ${PORT} freed`);
342
- resolve({
343
- accessToken: tokens.accessToken,
344
- refreshToken: tokens.refreshToken,
345
- });
324
+ const req = http.get({
325
+ host: '127.0.0.1',
326
+ port: server.port,
327
+ path: `/submit?input=${encodeURIComponent(pasted)}`,
328
+ agent: false,
346
329
  });
347
- };
348
- if (res) {
349
- res.send(successHtml);
350
- res.once('finish', closeServerAndResolve);
351
- // Fallback if the finish event doesn't fire promptly.
352
- finishTimeoutId = setTimeout(closeServerAndResolve, 1000);
353
- }
354
- else {
355
- closeServerAndResolve();
356
- }
357
- };
358
- // Exchange a code for tokens and resolve. Throws on exchange failure so the
359
- // caller decides: reject (auto callback) or keep waiting (manual paste).
360
- const completeWithCode = async (code, res) => {
361
- if (resolved)
362
- return;
363
- log?.info(`[browserAuth] Exchanging code for token...`);
364
- const tokens = await exchangeCodeForToken(authConfig, code, PORT, log);
365
- log?.info(`[browserAuth] Tokens received: accessToken(${tokens.accessToken?.length || 0} chars), refreshToken(${tokens.refreshToken?.length || 0} chars)`);
366
- finalizeSuccess(tokens, res);
367
- };
368
- // OAuth2 callback handler
369
- app.get('/callback', async (req, res) => {
370
- try {
371
- log?.info(`[browserAuth] Callback received: ${req.url}`);
372
- log?.debug(`Callback query: ${JSON.stringify(req.query)}`);
373
- // Check for OAuth2 error parameters
374
- const { error, error_description, error_uri } = req.query;
375
- if (error) {
376
- log?.error(`Callback error: ${error}${error_description ? ` - ${error_description}` : ''}`);
377
- const errorMsg = error_description
378
- ? `${error}: ${error_description}`
379
- : String(error);
380
- const errorHtml = `<!DOCTYPE html>
381
- <html lang="en">
382
- <head>
383
- <meta charset="UTF-8">
384
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
385
- <title>Authentication Error</title>
386
- <style>
387
- body {
388
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
389
- text-align: center;
390
- margin: 0;
391
- padding: 50px 20px;
392
- background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
393
- color: white;
394
- min-height: 100vh;
395
- display: flex;
396
- flex-direction: column;
397
- justify-content: center;
398
- align-items: center;
399
- }
400
- .container {
401
- background: rgba(255, 255, 255, 0.1);
402
- border-radius: 20px;
403
- padding: 40px;
404
- backdrop-filter: blur(10px);
405
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
406
- max-width: 500px;
407
- width: 100%;
330
+ req.on('error', () => undefined);
331
+ });
408
332
  }
409
- .error-icon {
410
- font-size: 4rem;
411
- margin-bottom: 20px;
412
- color: #fbbf24;
413
- text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
414
- }
415
- h1 {
416
- margin: 0 0 20px 0;
417
- font-size: 2rem;
418
- font-weight: 300;
419
- }
420
- p {
421
- margin: 0;
422
- font-size: 1.1rem;
423
- opacity: 0.9;
424
- line-height: 1.5;
425
- }
426
- </style>
427
- </head>
428
- <body>
429
- <div class="container">
430
- <div class="error-icon">✗</div>
431
- <h1>Authentication Failed</h1>
432
- <p>${errorMsg}</p>
433
- <p>Please check your service key configuration and try again.</p>
434
- </div>
435
- </body>
436
- </html>`;
437
- res.status(400).send(errorHtml);
438
- if (typeof server.closeAllConnections === 'function') {
439
- server.closeAllConnections();
440
- }
441
- server.close(() => {
442
- // Server closed on error
443
- });
444
- return reject(new Error(`OAuth2 authentication failed: ${errorMsg}${error_uri ? ` (${error_uri})` : ''}`));
445
- }
446
- const { code } = req.query;
447
- log?.info(`[browserAuth] Callback code received: ${code ? 'yes' : 'no'}`);
448
- log?.debug(`Callback code received: ${code ? 'yes' : 'no'}`);
449
- if (!code || typeof code !== 'string') {
450
- log?.error(`[browserAuth] Callback code missing`);
451
- res.status(400).send('Error: Authorization code missing');
452
- return reject(new Error('Authorization code missing'));
453
- }
454
- // Exchange code for tokens; on failure the auto-callback path rejects.
455
- try {
456
- await completeWithCode(code, res);
457
- }
458
- catch (error) {
459
- if (typeof server.closeAllConnections === 'function') {
460
- server.closeAllConnections();
461
- }
462
- // Use setTimeout to ensure connections are closed before server.close()
463
- setTimeout(() => {
464
- server.close(() => {
465
- log?.debug(`Server closed on error, port ${PORT} should be freed`);
466
- });
467
- }, 100);
468
- reject(error);
469
- }
470
- }
471
- catch (error) {
472
- res.status(500).send('Error processing authentication');
473
- if (typeof server.closeAllConnections === 'function') {
474
- server.closeAllConnections();
475
- }
476
- // Use setTimeout to ensure connections are closed before server.close()
477
- setTimeout(() => {
478
- server.close(() => {
479
- // Server closed on error - port should be freed
480
- log?.debug(`Server closed on error, port ${PORT} should be freed`);
481
- });
482
- }, 100);
483
- reject(error);
484
- }
485
- });
486
- // Manual paste form (served on the same already-listening server).
487
- app.get('/', (_req, res) => {
488
- res.send(pasteFormHtml());
489
- });
490
- // Manual paste submit: accept a bare code or a full redirected URL.
491
- app.get('/submit', async (req, res) => {
492
- const raw = req.query.input ?? req.query.code;
493
- const code = typeof raw === 'string' ? extractCode(raw) : null;
494
- if (!code) {
495
- res
496
- .status(400)
497
- .send(pasteFormHtml('Could not read an authorization code from that input. Try again.'));
498
- return;
499
- }
500
- try {
501
- await completeWithCode(code, res);
502
- }
503
- catch (error) {
504
- // Recoverable: a wrong/expired code shouldn't end the whole flow.
505
- log?.warn(`[browserAuth] Manual code exchange failed: ${error instanceof Error ? error.message : String(error)}`);
506
- res
507
- .status(400)
508
- .send(pasteFormHtml('Code exchange failed. Check the code and try again.'));
509
- }
510
- });
511
- // Handle server errors (e.g., EADDRINUSE)
512
- server.on('error', (error) => {
513
- if (error.code === 'EADDRINUSE') {
514
- log?.error(`Port ${PORT} is already in use. This should not happen after port check.`);
515
- reject(new Error(`Port ${PORT} is already in use. Please try again or specify a different port.`));
516
- }
517
- else {
518
- log?.error(`Server error: ${error.message}`);
519
- reject(error);
520
- }
521
- });
522
- serverInstance = server.listen(PORT, async () => {
523
- log?.info(`[browserAuth] Server started on port ${PORT}`);
524
- const browserApp = BROWSER_MAP[browser];
525
- // Handle 'none' and 'headless' modes - show URL and wait for the code
526
- // (for SSH/remote sessions or when no browser should be opened).
527
- // Use announce() so the URL is visible even without a logger.
528
- if (browser === 'none' || browser === 'headless') {
529
- announce(`🔗 Open this URL in your browser to authenticate:`);
530
- announce(` ${authorizationUrl}`);
531
- announce(` Waiting for callback on http://localhost:${PORT}/callback ...`);
532
- announce(` If your browser is on another machine, after login copy the ` +
533
- `\`code\` from the address bar and paste it at ` +
534
- `http://<this-host>:${PORT}/ — or paste it here and press Enter.`);
535
- // Manual stdin paste — only when attached to an interactive terminal.
536
- // Under a stdio RPC transport stdin carries the protocol, so we must
537
- // never consume it; isTTY guards that.
538
- if (process.stdin.isTTY) {
539
- stdinReader = readline.createInterface({ input: process.stdin });
540
- stdinReader.on('line', async (line) => {
541
- const code = extractCode(line);
542
- if (!code) {
543
- process.stderr.write('Could not read an authorization code from that input. Try again.\n');
544
- return;
545
- }
546
- try {
547
- await completeWithCode(code);
548
- }
549
- catch (error) {
550
- process.stderr.write(`Code exchange failed: ${error instanceof Error ? error.message : String(error)}. Try again.\n`);
551
- }
552
- });
553
- }
554
- // Don't open browser, don't reject - just wait for callback or paste.
555
- return;
556
- }
557
- // Handle 'auto' mode - try to open browser, fallback to showing URL
558
- if (browser === 'auto') {
559
- log?.info('🌐 Attempting to open browser for authentication...');
560
- try {
561
- const openModule = await Promise.resolve().then(() => __importStar(require('open')));
562
- const open = openModule.default;
563
- await open(authorizationUrl);
564
- log?.info('✅ Browser opened successfully. Waiting for authentication...');
565
- return;
566
- }
567
- catch (error) {
568
- // If browser cannot be opened, show URL and wait
569
- const errorMessage = error instanceof Error ? error.message : String(error);
570
- log?.warn(`⚠️ Could not open browser automatically: ${errorMessage}`);
571
- announce(`🔗 Please open this URL in your browser to authenticate:`);
572
- announce(` ${authorizationUrl}`);
573
- announce(` Waiting for callback on http://localhost:${PORT}/callback ...`);
574
- // Don't reject - wait for callback
575
- return;
576
- }
577
- }
578
- // Handle browser opening (system, chrome, edge, firefox)
579
- if (browser && browserApp !== null) {
580
- log?.debug('🌐 Opening browser for authentication...');
581
- // On Linux, ensure DISPLAY is set for X11 applications
582
- // This helps when running from terminals that don't set DISPLAY automatically
583
- if (process.platform === 'linux' &&
584
- !process.env.DISPLAY &&
585
- !process.env.WAYLAND_DISPLAY) {
586
- process.env.DISPLAY = ':0';
587
- log?.debug('DISPLAY not set, using fallback DISPLAY=:0');
588
- }
589
- try {
590
- // Try dynamic import first (for ES modules)
591
- let open;
592
- try {
593
- const openModule = await Promise.resolve().then(() => __importStar(require('open')));
594
- open = openModule.default;
595
- }
596
- catch (_importError) {
597
- // Fallback: use child_process to open browser if import fails
598
- // This works in both CommonJS and ES module environments (like Jest)
599
- const platform = process.platform;
600
- let command;
601
- if (browserApp === 'chrome') {
602
- command =
603
- platform === 'win32'
604
- ? 'cmd /c start "" "chrome"'
605
- : platform === 'darwin'
606
- ? 'open -a "Google Chrome"'
607
- : 'google-chrome || google-chrome-stable || chromium || chromium-browser';
608
- }
609
- else if (browserApp === 'edge') {
610
- command =
611
- platform === 'win32'
612
- ? 'cmd /c start "" "msedge"'
613
- : platform === 'darwin'
614
- ? 'open -a "Microsoft Edge"'
615
- : 'microsoft-edge || microsoft-edge-stable';
616
- }
617
- else if (browserApp === 'firefox') {
618
- command =
619
- platform === 'win32'
620
- ? 'cmd /c start "" "firefox"'
621
- : platform === 'darwin'
622
- ? 'open -a Firefox'
623
- : 'firefox || firefox-esr';
624
- }
625
- else {
626
- // System default
627
- command =
628
- platform === 'win32'
629
- ? 'cmd /c start ""'
630
- : platform === 'darwin'
631
- ? 'open'
632
- : 'xdg-open';
633
- }
634
- // Use child_process as fallback (non-blocking)
635
- child_process.exec(`${command} "${authorizationUrl}"`, (error) => {
636
- if (error) {
637
- log?.error(`❌ Failed to open browser: ${error.message}. Please open manually: ${authorizationUrl}`, { error: error.message, url: authorizationUrl });
638
- }
639
- });
640
- return; // Exit early since we're using child_process (non-blocking)
641
- }
642
- // Use open module if import succeeded
643
- if (browserApp) {
644
- await open(authorizationUrl, { app: { name: browserApp } });
645
- }
646
- else {
647
- await open(authorizationUrl);
648
- }
649
- }
650
- catch (error) {
651
- // If browser cannot be opened, close server and show URL
652
- if (typeof server.closeAllConnections === 'function') {
653
- server.closeAllConnections();
654
- }
655
- // Use setTimeout to ensure connections are closed before server.close()
656
- setTimeout(() => {
657
- server.close(() => {
658
- // Server closed on browser open error - port should be freed
659
- log?.debug(`Server closed on browser open error, port ${PORT} should be freed`);
660
- });
661
- }, 100);
662
- const errorMessage = error instanceof Error ? error.message : String(error);
663
- log?.error(`❌ Failed to open browser: ${errorMessage}. Please open manually: ${authorizationUrl}`, { error: errorMessage, url: authorizationUrl });
664
- log?.info(`🔗 Open in browser: ${authorizationUrl}`, {
665
- url: authorizationUrl,
666
- });
667
- // Throw error so consumer can distinguish this from "service key missing" error
668
- reject(new Error(`Browser opening failed for destination authentication. Please open manually: ${authorizationUrl}`));
669
- }
670
- }
671
- });
672
- // Timeout after 30 seconds to prevent blocking consumer
673
- timeoutId = setTimeout(() => {
674
- if (serverInstance) {
675
- if (typeof server.closeAllConnections === 'function') {
676
- server.closeAllConnections();
677
- }
678
- // Use setTimeout to ensure connections are closed before server.close()
679
- setTimeout(() => {
680
- server.close(() => {
681
- // Server closed on timeout - port should be freed
682
- log?.debug(`Server closed on timeout, port ${PORT} should be freed`);
683
- });
684
- }, 100);
685
- reject(new Error('Authentication timeout after 30 seconds. Please try again.'));
686
- }
687
- }, 30 * 1000);
333
+ return await waiting;
334
+ }).finally(() => {
335
+ stdinReader?.close();
336
+ stdinReader = null;
688
337
  });
338
+ log?.info('[browserAuth] Exchanging code for token...');
339
+ return await exchangeCodeForToken(authConfig, code, port, log);
689
340
  }