@mcp-abap-adt/auth-providers 1.0.5 → 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.
@@ -39,13 +39,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
39
39
  return (mod && mod.__esModule) ? mod : { "default": mod };
40
40
  };
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.extractCode = extractCode;
42
43
  exports.exchangeCodeForToken = exchangeCodeForToken;
43
44
  exports.startBrowserAuth = startBrowserAuth;
44
45
  const child_process = __importStar(require("node:child_process"));
45
46
  const http = __importStar(require("node:http"));
46
47
  const net = __importStar(require("node:net"));
48
+ const readline = __importStar(require("node:readline"));
47
49
  const axios_1 = __importDefault(require("axios"));
48
- const express_1 = __importDefault(require("express"));
50
+ const callbackServer_1 = require("./callbackServer");
49
51
  const BROWSER_MAP = {
50
52
  chrome: 'chrome',
51
53
  edge: 'msedge',
@@ -55,6 +57,37 @@ const BROWSER_MAP = {
55
57
  headless: null, // no browser, log URL and wait for callback (SSH/remote)
56
58
  none: null, // no browser, log URL and wait for callback (same as headless)
57
59
  };
60
+ /**
61
+ * Extract an OAuth2 authorization code from arbitrary pasted input.
62
+ *
63
+ * Accepts:
64
+ * - a bare code: `abc123`
65
+ * - `code=abc123`
66
+ * - a full redirected URL: `http://localhost:7779/callback?code=abc123&state=...`
67
+ *
68
+ * Returns the decoded code, or null if nothing usable was found.
69
+ * @internal - Exported for testing and for manual-paste flows.
70
+ */
71
+ function extractCode(input) {
72
+ if (!input)
73
+ return null;
74
+ const trimmed = input.trim();
75
+ if (!trimmed)
76
+ return null;
77
+ // Anywhere a `code=` query parameter appears (full URL or query string)
78
+ const fromQuery = trimmed.match(/[?&]code=([^&\s]+)/);
79
+ if (fromQuery)
80
+ return decodeURIComponent(fromQuery[1]);
81
+ // Bare `code=XYZ`
82
+ const bareKv = trimmed.match(/^code=([^&\s]+)$/);
83
+ if (bareKv)
84
+ return decodeURIComponent(bareKv[1]);
85
+ // Otherwise treat the whole token as the code, but reject anything with
86
+ // whitespace (clearly not a single code).
87
+ if (/\s/.test(trimmed))
88
+ return null;
89
+ return trimmed;
90
+ }
58
91
  /**
59
92
  * Get OAuth2 authorization URL
60
93
  */
@@ -135,482 +168,173 @@ function isPortAvailable(port) {
135
168
  * @param port Port for OAuth callback server (default: 3001)
136
169
  * @returns Promise that resolves to tokens
137
170
  * @internal - Internal function, not exported from package
138
- */
139
- async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3001) {
140
- // Use logger if provided, otherwise null (no logging)
141
- const log = logger || null;
142
- // Check if requested port is available, throw error if not
143
- const portAvailable = await isPortAvailable(port);
144
- if (!portAvailable) {
145
- throw new Error(`Port ${port} is already in use. Please specify a different port or free the port.`);
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;
146
190
  }
147
- return new Promise((originalResolve, originalReject) => {
148
- let timeoutId = null;
149
- let finishTimeoutId = null;
150
- let cleanupDone = false;
151
- let resolved = false;
152
- const app = (0, express_1.default)();
153
- const server = http.createServer(app);
154
- // Disable keep-alive to ensure connections close immediately
155
- server.keepAliveTimeout = 0;
156
- server.headersTimeout = 0;
157
- const PORT = port;
158
- let serverInstance = null;
159
- // Cleanup function to ensure server is closed on process termination
160
- const cleanup = () => {
161
- if (cleanupDone)
162
- return;
163
- cleanupDone = true;
164
- log?.debug(`Cleaning up OAuth callback server on port ${PORT}`);
165
- if (timeoutId) {
166
- clearTimeout(timeoutId);
167
- timeoutId = null;
168
- }
169
- if (finishTimeoutId) {
170
- clearTimeout(finishTimeoutId);
171
- finishTimeoutId = null;
172
- }
173
- if (server) {
174
- try {
175
- if (typeof server.closeAllConnections === 'function') {
176
- server.closeAllConnections();
177
- }
178
- server.close(() => {
179
- log?.debug(`OAuth server closed during cleanup, port ${PORT} freed`);
180
- });
181
- }
182
- catch (_e) {
183
- // Ignore errors during cleanup
184
- }
185
- }
186
- };
187
- // Remove cleanup listeners to prevent memory leaks
188
- const removeCleanupListeners = () => {
189
- process.removeListener('exit', cleanup);
190
- process.removeListener('SIGTERM', cleanup);
191
- process.removeListener('SIGINT', cleanup);
192
- process.removeListener('SIGHUP', cleanup);
193
- if (process.platform === 'win32') {
194
- process.removeListener('SIGBREAK', cleanup);
195
- }
196
- };
197
- const resolve = (value) => {
198
- if (resolved)
199
- return; // Prevent double resolution
200
- resolved = true;
201
- if (timeoutId) {
202
- clearTimeout(timeoutId);
203
- timeoutId = null;
204
- }
205
- if (finishTimeoutId) {
206
- clearTimeout(finishTimeoutId);
207
- finishTimeoutId = null;
208
- }
209
- removeCleanupListeners();
210
- originalResolve(value);
211
- };
212
- const reject = (reason) => {
213
- if (timeoutId)
214
- clearTimeout(timeoutId);
215
- removeCleanupListeners();
216
- originalReject(reason);
217
- };
218
- // Register cleanup handlers for process termination
219
- // This ensures port is freed when Cline or other clients kill the process
220
- process.once('exit', cleanup);
221
- process.once('SIGTERM', cleanup);
222
- process.once('SIGINT', cleanup);
223
- process.once('SIGHUP', cleanup);
224
- // SIGBREAK is Windows-specific (Ctrl+Break)
225
- if (process.platform === 'win32') {
226
- process.once('SIGBREAK', cleanup);
227
- }
228
- // Use provided authorization URL or build from authConfig
229
- const authorizationUrl = authConfig.authorizationUrl ?? getJwtAuthorizationUrl(authConfig, PORT);
230
- log?.info(`[browserAuth] Authorization URL: ${authorizationUrl}`);
231
- log?.info(`[browserAuth] Server listening on port: ${PORT}`);
232
- // Verify port in redirect_uri matches server port
233
- const redirectUriMatch = authorizationUrl.match(/redirect_uri=([^&]+)/);
234
- if (redirectUriMatch) {
235
- const redirectUri = decodeURIComponent(redirectUriMatch[1]);
236
- const urlPortMatch = redirectUri.match(/localhost:(\d+)/);
237
- if (urlPortMatch) {
238
- const urlPort = parseInt(urlPortMatch[1], 10);
239
- if (urlPort !== PORT) {
240
- log?.warn(`[browserAuth] WARNING: Port mismatch! URL has port ${urlPort}, but server listens on ${PORT}`);
241
- }
242
- else {
243
- log?.info(`[browserAuth] Port match: URL and server both use port ${PORT}`);
244
- }
245
- }
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...');
246
197
  }
247
- // OAuth2 callback handler
248
- app.get('/callback', async (req, res) => {
249
- try {
250
- log?.info(`[browserAuth] Callback received: ${req.url}`);
251
- log?.debug(`Callback query: ${JSON.stringify(req.query)}`);
252
- // Check for OAuth2 error parameters
253
- const { error, error_description, error_uri } = req.query;
254
- if (error) {
255
- log?.error(`Callback error: ${error}${error_description ? ` - ${error_description}` : ''}`);
256
- const errorMsg = error_description
257
- ? `${error}: ${error_description}`
258
- : String(error);
259
- const errorHtml = `<!DOCTYPE html>
260
- <html lang="en">
261
- <head>
262
- <meta charset="UTF-8">
263
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
264
- <title>Authentication Error</title>
265
- <style>
266
- body {
267
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
268
- text-align: center;
269
- margin: 0;
270
- padding: 50px 20px;
271
- background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
272
- color: white;
273
- min-height: 100vh;
274
- display: flex;
275
- flex-direction: column;
276
- justify-content: center;
277
- align-items: center;
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 ...`);
278
204
  }
279
- .container {
280
- background: rgba(255, 255, 255, 0.1);
281
- border-radius: 20px;
282
- padding: 40px;
283
- backdrop-filter: blur(10px);
284
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
285
- max-width: 500px;
286
- width: 100%;
287
- }
288
- .error-icon {
289
- font-size: 4rem;
290
- margin-bottom: 20px;
291
- color: #fbbf24;
292
- text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
293
- }
294
- h1 {
295
- margin: 0 0 20px 0;
296
- font-size: 2rem;
297
- font-weight: 300;
298
- }
299
- p {
300
- margin: 0;
301
- font-size: 1.1rem;
302
- opacity: 0.9;
303
- line-height: 1.5;
304
- }
305
- </style>
306
- </head>
307
- <body>
308
- <div class="container">
309
- <div class="error-icon">✗</div>
310
- <h1>Authentication Failed</h1>
311
- <p>${errorMsg}</p>
312
- <p>Please check your service key configuration and try again.</p>
313
- </div>
314
- </body>
315
- </html>`;
316
- res.status(400).send(errorHtml);
317
- if (typeof server.closeAllConnections === 'function') {
318
- server.closeAllConnections();
319
- }
320
- server.close(() => {
321
- // Server closed on error
322
- });
323
- return reject(new Error(`OAuth2 authentication failed: ${errorMsg}${error_uri ? ` (${error_uri})` : ''}`));
324
- }
325
- const { code } = req.query;
326
- log?.info(`[browserAuth] Callback code received: ${code ? 'yes' : 'no'}`);
327
- log?.debug(`Callback code received: ${code ? 'yes' : 'no'}`);
328
- if (!code || typeof code !== 'string') {
329
- log?.error(`[browserAuth] Callback code missing`);
330
- res.status(400).send('Error: Authorization code missing');
331
- return reject(new Error('Authorization code missing'));
332
- }
333
- log?.info(`[browserAuth] Exchanging code for token...`);
334
- // Send success page
335
- const html = `<!DOCTYPE html>
336
- <html lang="en">
337
- <head>
338
- <meta charset="UTF-8">
339
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
340
- <title>SAP BTP Authentication</title>
341
- <style>
342
- body {
343
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
344
- text-align: center;
345
- margin: 0;
346
- padding: 50px 20px;
347
- background: linear-gradient(135deg, #0070f3 0%, #00d4ff 100%);
348
- color: white;
349
- min-height: 100vh;
350
- display: flex;
351
- flex-direction: column;
352
- justify-content: center;
353
- align-items: center;
354
- }
355
- .container {
356
- background: rgba(255, 255, 255, 0.1);
357
- border-radius: 20px;
358
- padding: 40px;
359
- backdrop-filter: blur(10px);
360
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
361
- max-width: 500px;
362
- width: 100%;
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';
363
235
  }
364
- .success-icon {
365
- font-size: 4rem;
366
- margin-bottom: 20px;
367
- color: #4ade80;
368
- text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
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';
369
243
  }
370
- h1 {
371
- margin: 0 0 20px 0;
372
- font-size: 2rem;
373
- font-weight: 300;
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';
374
251
  }
375
- p {
376
- margin: 0;
377
- font-size: 1.1rem;
378
- opacity: 0.9;
379
- line-height: 1.5;
252
+ else {
253
+ command =
254
+ platform === 'win32'
255
+ ? 'cmd /c start ""'
256
+ : platform === 'darwin'
257
+ ? 'open'
258
+ : 'xdg-open';
380
259
  }
381
- </style>
382
- </head>
383
- <body>
384
- <div class="container">
385
- <div class="success-icon">✓</div>
386
- <h1>Authentication Successful!</h1>
387
- <p>You have successfully authenticated with SAP BTP.</p>
388
- <p>You can now close this browser window.</p>
389
- </div>
390
- </body>
391
- </html>`;
392
- // Exchange code for tokens first
393
- try {
394
- log?.info(`[browserAuth] Starting token exchange...`);
395
- const tokens = await exchangeCodeForToken(authConfig, code, PORT, log);
396
- log?.info(`[browserAuth] Tokens received: accessToken(${tokens.accessToken?.length || 0} chars), refreshToken(${tokens.refreshToken?.length || 0} chars)`);
397
- // Send success page (non-blocking, doesn't affect promise)
398
- res.send(html);
399
- log?.info(`[browserAuth] Response sent, waiting for finish...`);
400
- // Close all connections and server after response is sent
401
- // Resolve promise AFTER server is closed to prevent Jest from hanging
402
- let serverClosing = false;
403
- const closeServerAndResolve = () => {
404
- if (serverClosing)
405
- return; // Prevent double execution
406
- serverClosing = true;
407
- if (finishTimeoutId) {
408
- clearTimeout(finishTimeoutId);
409
- finishTimeoutId = null;
410
- }
411
- log?.info(`[browserAuth] Response finished, closing server...`);
412
- if (typeof server.closeAllConnections === 'function') {
413
- server.closeAllConnections();
414
- }
415
- // Wait for server to close before resolving to prevent Jest from hanging
416
- server.close(() => {
417
- // Server closed - port should be freed
418
- log?.info(`[browserAuth] Server closed, port ${PORT} should be freed`);
419
- // Resolve after server is fully closed
420
- log?.info(`[browserAuth] Resolving promise with tokens...`);
421
- resolve({
422
- accessToken: tokens.accessToken,
423
- refreshToken: tokens.refreshToken,
424
- });
425
- });
426
- };
427
- // Wait for response to finish, but add timeout to prevent hanging
428
- res.once('finish', closeServerAndResolve);
429
- // Fallback: if finish event doesn't fire within 1 second, close anyway
430
- finishTimeoutId = setTimeout(closeServerAndResolve, 1000);
431
- }
432
- catch (error) {
433
- if (typeof server.closeAllConnections === 'function') {
434
- server.closeAllConnections();
435
- }
436
- // Use setTimeout to ensure connections are closed before server.close()
437
- setTimeout(() => {
438
- server.close(() => {
439
- // Server closed on error - port should be freed
440
- log?.debug(`Server closed on error, port ${PORT} should be freed`);
441
- });
442
- }, 100);
443
- reject(error);
444
- }
445
- }
446
- catch (error) {
447
- res.status(500).send('Error processing authentication');
448
- if (typeof server.closeAllConnections === 'function') {
449
- server.closeAllConnections();
450
- }
451
- // Use setTimeout to ensure connections are closed before server.close()
452
- setTimeout(() => {
453
- server.close(() => {
454
- // Server closed on error - port should be freed
455
- log?.debug(`Server closed on error, port ${PORT} should be freed`);
456
- });
457
- }, 100);
458
- reject(error);
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 });
459
263
  }
460
264
  });
461
- // Handle server errors (e.g., EADDRINUSE)
462
- server.on('error', (error) => {
463
- if (error.code === 'EADDRINUSE') {
464
- log?.error(`Port ${PORT} is already in use. This should not happen after port check.`);
465
- reject(new Error(`Port ${PORT} is already in use. Please try again or specify a different port.`));
466
- }
467
- else {
468
- log?.error(`Server error: ${error.message}`);
469
- reject(error);
470
- }
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.
279
+ */
280
+ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3001, timeoutMs = 30 * 1000) {
281
+ const log = logger || null;
282
+ // Essential, user-facing prompts (the auth URL, paste instructions) must be
283
+ // visible even when no logger is supplied. Fall back to stderr — never stdout,
284
+ // so stdio-based RPC transports (MCP/LSP) are not corrupted.
285
+ const announce = (msg) => {
286
+ if (log)
287
+ log.info(msg);
288
+ else
289
+ process.stderr.write(`${msg}\n`);
290
+ };
291
+ // Pre-check kept for its message: AuthBroker matches /already in use/i to
292
+ // distinguish a busy port from other failures.
293
+ const portAvailable = await isPortAvailable(port);
294
+ if (!portAvailable) {
295
+ throw new Error(`Port ${port} is already in use. Please specify a different port or free the port.`);
296
+ }
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);
301
+ log?.info(`[browserAuth] Authorization URL: ${authorizationUrl}`);
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}`));
471
310
  });
472
- serverInstance = server.listen(PORT, async () => {
473
- log?.info(`[browserAuth] Server started on port ${PORT}`);
474
- const browserApp = BROWSER_MAP[browser];
475
- // Handle 'none' and 'headless' modes - log URL and wait for callback
476
- // (for SSH/remote sessions or when browser should not be opened)
477
- if (browser === 'none' || browser === 'headless') {
478
- log?.info(`🔗 Open this URL in your browser to authenticate:`);
479
- log?.info(` ${authorizationUrl}`);
480
- log?.info(` Waiting for callback on http://localhost:${PORT}/callback ...`);
481
- // Don't open browser, don't reject - just wait for the callback
482
- return;
483
- }
484
- // Handle 'auto' mode - try to open browser, fallback to showing URL
485
- if (browser === 'auto') {
486
- log?.info('🌐 Attempting to open browser for authentication...');
487
- try {
488
- const openModule = await Promise.resolve().then(() => __importStar(require('open')));
489
- const open = openModule.default;
490
- await open(authorizationUrl);
491
- log?.info('✅ Browser opened successfully. Waiting for authentication...');
492
- return;
493
- }
494
- catch (error) {
495
- // If browser cannot be opened, show URL and wait
496
- const errorMessage = error instanceof Error ? error.message : String(error);
497
- log?.warn(`⚠️ Could not open browser automatically: ${errorMessage}`);
498
- log?.info(`🔗 Please open this URL in your browser to authenticate:`);
499
- log?.info(` ${authorizationUrl}`);
500
- log?.info(` Waiting for callback on http://localhost:${PORT}/callback ...`);
501
- // Don't reject - wait for callback
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');
502
322
  return;
503
323
  }
504
- }
505
- // Handle browser opening (system, chrome, edge, firefox)
506
- if (browser && browserApp !== null) {
507
- log?.debug('🌐 Opening browser for authentication...');
508
- // On Linux, ensure DISPLAY is set for X11 applications
509
- // This helps when running from terminals that don't set DISPLAY automatically
510
- if (process.platform === 'linux' &&
511
- !process.env.DISPLAY &&
512
- !process.env.WAYLAND_DISPLAY) {
513
- process.env.DISPLAY = ':0';
514
- log?.debug('DISPLAY not set, using fallback DISPLAY=:0');
515
- }
516
- try {
517
- // Try dynamic import first (for ES modules)
518
- let open;
519
- try {
520
- const openModule = await Promise.resolve().then(() => __importStar(require('open')));
521
- open = openModule.default;
522
- }
523
- catch (_importError) {
524
- // Fallback: use child_process to open browser if import fails
525
- // This works in both CommonJS and ES module environments (like Jest)
526
- const platform = process.platform;
527
- let command;
528
- if (browserApp === 'chrome') {
529
- command =
530
- platform === 'win32'
531
- ? 'cmd /c start "" "chrome"'
532
- : platform === 'darwin'
533
- ? 'open -a "Google Chrome"'
534
- : 'google-chrome || google-chrome-stable || chromium || chromium-browser';
535
- }
536
- else if (browserApp === 'edge') {
537
- command =
538
- platform === 'win32'
539
- ? 'cmd /c start "" "msedge"'
540
- : platform === 'darwin'
541
- ? 'open -a "Microsoft Edge"'
542
- : 'microsoft-edge || microsoft-edge-stable';
543
- }
544
- else if (browserApp === 'firefox') {
545
- command =
546
- platform === 'win32'
547
- ? 'cmd /c start "" "firefox"'
548
- : platform === 'darwin'
549
- ? 'open -a Firefox'
550
- : 'firefox || firefox-esr';
551
- }
552
- else {
553
- // System default
554
- command =
555
- platform === 'win32'
556
- ? 'cmd /c start ""'
557
- : platform === 'darwin'
558
- ? 'open'
559
- : 'xdg-open';
560
- }
561
- // Use child_process as fallback (non-blocking)
562
- child_process.exec(`${command} "${authorizationUrl}"`, (error) => {
563
- if (error) {
564
- log?.error(`❌ Failed to open browser: ${error.message}. Please open manually: ${authorizationUrl}`, { error: error.message, url: authorizationUrl });
565
- }
566
- });
567
- return; // Exit early since we're using child_process (non-blocking)
568
- }
569
- // Use open module if import succeeded
570
- if (browserApp) {
571
- await open(authorizationUrl, { app: { name: browserApp } });
572
- }
573
- else {
574
- await open(authorizationUrl);
575
- }
576
- }
577
- catch (error) {
578
- // If browser cannot be opened, close server and show URL
579
- if (typeof server.closeAllConnections === 'function') {
580
- server.closeAllConnections();
581
- }
582
- // Use setTimeout to ensure connections are closed before server.close()
583
- setTimeout(() => {
584
- server.close(() => {
585
- // Server closed on browser open error - port should be freed
586
- log?.debug(`Server closed on browser open error, port ${PORT} should be freed`);
587
- });
588
- }, 100);
589
- const errorMessage = error instanceof Error ? error.message : String(error);
590
- log?.error(`❌ Failed to open browser: ${errorMessage}. Please open manually: ${authorizationUrl}`, { error: errorMessage, url: authorizationUrl });
591
- log?.info(`🔗 Open in browser: ${authorizationUrl}`, {
592
- url: authorizationUrl,
593
- });
594
- // Throw error so consumer can distinguish this from "service key missing" error
595
- reject(new Error(`Browser opening failed for destination authentication. Please open manually: ${authorizationUrl}`));
596
- }
597
- }
598
- });
599
- // Timeout after 30 seconds to prevent blocking consumer
600
- timeoutId = setTimeout(() => {
601
- if (serverInstance) {
602
- if (typeof server.closeAllConnections === 'function') {
603
- server.closeAllConnections();
604
- }
605
- // Use setTimeout to ensure connections are closed before server.close()
606
- setTimeout(() => {
607
- server.close(() => {
608
- // Server closed on timeout - port should be freed
609
- log?.debug(`Server closed on timeout, port ${PORT} should be freed`);
610
- });
611
- }, 100);
612
- reject(new Error('Authentication timeout after 30 seconds. Please try again.'));
613
- }
614
- }, 30 * 1000);
324
+ const req = http.get({
325
+ host: '127.0.0.1',
326
+ port: server.port,
327
+ path: `/submit?input=${encodeURIComponent(pasted)}`,
328
+ agent: false,
329
+ });
330
+ req.on('error', () => undefined);
331
+ });
332
+ }
333
+ return await waiting;
334
+ }).finally(() => {
335
+ stdinReader?.close();
336
+ stdinReader = null;
615
337
  });
338
+ log?.info('[browserAuth] Exchanging code for token...');
339
+ return await exchangeCodeForToken(authConfig, code, port, log);
616
340
  }