@mcp-abap-adt/auth-providers 1.0.5 → 1.1.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0] - 2026-06-02
11
+
12
+ ### Added
13
+ - `extractCode(input)` helper: pulls an OAuth2 authorization code out of a bare
14
+ code, a `code=...` string, or a full redirected URL.
15
+ - Manual paste authentication for `none`/`headless` browser modes, so login can
16
+ complete when the automatic `localhost` callback can't reach the process
17
+ (browser on another machine / container / SSH). Three racing channels, first
18
+ one wins:
19
+ - the existing automatic `GET /callback?code=...` redirect;
20
+ - an HTML paste form on the same callback server (`GET /` + `GET /submit`);
21
+ - stdin paste, only when `process.stdin.isTTY` (never consumed under stdio
22
+ RPC transports).
23
+
24
+ ### Fixed
25
+ - `none`/`headless` mode now prints the authorization URL even when no logger is
26
+ supplied. The prompt previously rode on `log?.info(...)` and was silently
27
+ dropped without a logger; it now falls back to `stderr` (never stdout, to keep
28
+ stdio RPC transports uncorrupted).
29
+
10
30
  ## [1.0.5] - 2026-02-12
11
31
 
12
32
  ### Fixed
package/README.md CHANGED
@@ -1,4 +1,5 @@
1
1
  # @mcp-abap-adt/auth-providers
2
+ [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://stand-with-ukraine.pp.ua)
2
3
 
3
4
  Token providers for MCP ABAP ADT auth-broker.
4
5
 
@@ -99,6 +100,36 @@ const clientCredsBroker = new AuthBroker({
99
100
  }, 'none');
100
101
  ```
101
102
 
103
+ ### Browser modes (`AuthorizationCodeProvider`)
104
+
105
+ The `browser` option controls how the authorization URL is opened:
106
+
107
+ | Mode | Behaviour |
108
+ |------|-----------|
109
+ | `system` (default) | Open the OS default browser |
110
+ | `chrome` / `edge` / `firefox` | Open a specific browser |
111
+ | `auto` | Try to open a browser; on failure, print the URL and wait |
112
+ | `none` / `headless` | Do **not** open a browser — print the URL and wait for the code (SSH / remote / containers) |
113
+
114
+ In `none`/`headless` mode the authorization URL is always shown, **even when no
115
+ logger is supplied** (it falls back to `stderr`, never stdout, so stdio-based
116
+ RPC transports are not corrupted).
117
+
118
+ #### Manual paste (none / headless)
119
+
120
+ Login can complete through any of three channels — whichever finishes first wins:
121
+
122
+ 1. **Automatic callback** — `GET /callback?code=...` on `http://localhost:<redirectPort>`.
123
+ Works when the browser is on the same machine as the process.
124
+ 2. **Paste form** — open `http://<host>:<redirectPort>/` and paste the code (or
125
+ the whole redirected URL). Works when the browser is on a *different* machine,
126
+ since the callback server listens on all interfaces.
127
+ 3. **Terminal paste** — paste the code on stdin and press Enter. Only active when
128
+ `process.stdin.isTTY` (stdin is never consumed under a stdio RPC transport).
129
+
130
+ The exported `extractCode(input)` helper accepts a bare code, `code=...`, or a
131
+ full redirected URL.
132
+
102
133
  ### SSO Providers
103
134
 
104
135
  This package also includes SSO providers for OIDC and SAML2, plus a small factory for DI-friendly creation.
@@ -5,6 +5,18 @@ import type { IAuthorizationConfig, ILogger } from '@mcp-abap-adt/interfaces';
5
5
  type BrowserAuthConfig = IAuthorizationConfig & {
6
6
  authorizationUrl?: string;
7
7
  };
8
+ /**
9
+ * Extract an OAuth2 authorization code from arbitrary pasted input.
10
+ *
11
+ * Accepts:
12
+ * - a bare code: `abc123`
13
+ * - `code=abc123`
14
+ * - a full redirected URL: `http://localhost:7779/callback?code=abc123&state=...`
15
+ *
16
+ * Returns the decoded code, or null if nothing usable was found.
17
+ * @internal - Exported for testing and for manual-paste flows.
18
+ */
19
+ export declare function extractCode(input: string): string | null;
8
20
  /**
9
21
  * Exchange authorization code for tokens
10
22
  * @internal - Exported for testing
@@ -1 +1 @@
1
- {"version":3,"file":"browserAuth.d.ts","sourceRoot":"","sources":["../../src/auth/browserAuth.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,OAAO,KAAK,EAAE,oBAAoB,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAI9E,KAAK,iBAAiB,GAAG,oBAAoB,GAAG;IAC9C,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;AA8BF;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,UAAU,EAAE,oBAAoB,EAChC,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE,MAAa,EACnB,GAAG,CAAC,EAAE,OAAO,GAAG,IAAI,GACnB,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAgDzD;AA6BD;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CACpC,UAAU,EAAE,iBAAiB,EAC7B,OAAO,GAAE,MAAiB,EAC1B,MAAM,CAAC,EAAE,OAAO,EAChB,IAAI,GAAE,MAAa,GAClB,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAqjBzD"}
1
+ {"version":3,"file":"browserAuth.d.ts","sourceRoot":"","sources":["../../src/auth/browserAuth.ts"],"names":[],"mappings":"AAAA;;GAEG;AAMH,OAAO,KAAK,EAAE,oBAAoB,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAI9E,KAAK,iBAAiB,GAAG,oBAAoB,GAAG;IAC9C,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;AAYF;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiBxD;AAoBD;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,UAAU,EAAE,oBAAoB,EAChC,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE,MAAa,EACnB,GAAG,CAAC,EAAE,OAAO,GAAG,IAAI,GACnB,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAgDzD;AA6BD;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CACpC,UAAU,EAAE,iBAAiB,EAC7B,OAAO,GAAE,MAAiB,EAC1B,MAAM,CAAC,EAAE,OAAO,EAChB,IAAI,GAAE,MAAa,GAClB,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAumBzD"}
@@ -39,11 +39,13 @@ 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
50
  const express_1 = __importDefault(require("express"));
49
51
  const BROWSER_MAP = {
@@ -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
  */
@@ -139,6 +172,15 @@ function isPortAvailable(port) {
139
172
  async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3001) {
140
173
  // Use logger if provided, otherwise null (no logging)
141
174
  const log = logger || null;
175
+ // Essential, user-facing prompts (the auth URL, paste instructions) must be
176
+ // visible even when no logger is supplied. Fall back to stderr — never stdout,
177
+ // so stdio-based RPC transports (MCP/LSP) are not corrupted.
178
+ const announce = (msg) => {
179
+ if (log)
180
+ log.info(msg);
181
+ else
182
+ process.stderr.write(`${msg}\n`);
183
+ };
142
184
  // Check if requested port is available, throw error if not
143
185
  const portAvailable = await isPortAvailable(port);
144
186
  if (!portAvailable) {
@@ -149,6 +191,14 @@ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3
149
191
  let finishTimeoutId = null;
150
192
  let cleanupDone = false;
151
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
+ };
152
202
  const app = (0, express_1.default)();
153
203
  const server = http.createServer(app);
154
204
  // Disable keep-alive to ensure connections close immediately
@@ -170,6 +220,7 @@ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3
170
220
  clearTimeout(finishTimeoutId);
171
221
  finishTimeoutId = null;
172
222
  }
223
+ stopStdin();
173
224
  if (server) {
174
225
  try {
175
226
  if (typeof server.closeAllConnections === 'function') {
@@ -206,6 +257,7 @@ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3
206
257
  clearTimeout(finishTimeoutId);
207
258
  finishTimeoutId = null;
208
259
  }
260
+ stopStdin();
209
261
  removeCleanupListeners();
210
262
  originalResolve(value);
211
263
  };
@@ -244,6 +296,75 @@ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3
244
296
  }
245
297
  }
246
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)
331
+ return;
332
+ serverClosing = true;
333
+ if (finishTimeoutId) {
334
+ clearTimeout(finishTimeoutId);
335
+ finishTimeoutId = null;
336
+ }
337
+ if (typeof server.closeAllConnections === 'function') {
338
+ server.closeAllConnections();
339
+ }
340
+ server.close(() => {
341
+ log?.info(`[browserAuth] Server closed, port ${PORT} freed`);
342
+ resolve({
343
+ accessToken: tokens.accessToken,
344
+ refreshToken: tokens.refreshToken,
345
+ });
346
+ });
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
+ };
247
368
  // OAuth2 callback handler
248
369
  app.get('/callback', async (req, res) => {
249
370
  try {
@@ -330,104 +451,9 @@ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3
330
451
  res.status(400).send('Error: Authorization code missing');
331
452
  return reject(new Error('Authorization code missing'));
332
453
  }
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%;
363
- }
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);
369
- }
370
- h1 {
371
- margin: 0 0 20px 0;
372
- font-size: 2rem;
373
- font-weight: 300;
374
- }
375
- p {
376
- margin: 0;
377
- font-size: 1.1rem;
378
- opacity: 0.9;
379
- line-height: 1.5;
380
- }
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
454
+ // Exchange code for tokens; on failure the auto-callback path rejects.
393
455
  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);
456
+ await completeWithCode(code, res);
431
457
  }
432
458
  catch (error) {
433
459
  if (typeof server.closeAllConnections === 'function') {
@@ -436,7 +462,6 @@ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3
436
462
  // Use setTimeout to ensure connections are closed before server.close()
437
463
  setTimeout(() => {
438
464
  server.close(() => {
439
- // Server closed on error - port should be freed
440
465
  log?.debug(`Server closed on error, port ${PORT} should be freed`);
441
466
  });
442
467
  }, 100);
@@ -458,6 +483,31 @@ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3
458
483
  reject(error);
459
484
  }
460
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
+ });
461
511
  // Handle server errors (e.g., EADDRINUSE)
462
512
  server.on('error', (error) => {
463
513
  if (error.code === 'EADDRINUSE') {
@@ -472,13 +522,36 @@ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3
472
522
  serverInstance = server.listen(PORT, async () => {
473
523
  log?.info(`[browserAuth] Server started on port ${PORT}`);
474
524
  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)
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.
477
528
  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
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.
482
555
  return;
483
556
  }
484
557
  // Handle 'auto' mode - try to open browser, fallback to showing URL
@@ -495,9 +568,9 @@ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3
495
568
  // If browser cannot be opened, show URL and wait
496
569
  const errorMessage = error instanceof Error ? error.message : String(error);
497
570
  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 ...`);
571
+ announce(`🔗 Please open this URL in your browser to authenticate:`);
572
+ announce(` ${authorizationUrl}`);
573
+ announce(` Waiting for callback on http://localhost:${PORT}/callback ...`);
501
574
  // Don't reject - wait for callback
502
575
  return;
503
576
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcp-abap-adt/auth-providers",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "Token providers for MCP ABAP ADT auth-broker",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",