@mcp-abap-adt/auth-providers 1.0.4 → 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 +25 -0
- package/README.md +31 -0
- package/dist/auth/browserAuth.d.ts +12 -0
- package/dist/auth/browserAuth.d.ts.map +1 -1
- package/dist/auth/browserAuth.js +180 -107
- package/dist/auth/saml2TokenExchange.d.ts.map +1 -1
- package/dist/auth/saml2TokenExchange.js +13 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ 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
|
+
|
|
30
|
+
## [1.0.5] - 2026-02-12
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- Log SAML bearer token exchange HTTP errors with response details for troubleshooting.
|
|
34
|
+
|
|
10
35
|
## [1.0.4] - 2026-02-11
|
|
11
36
|
|
|
12
37
|
### Changed
|
package/README.md
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# @mcp-abap-adt/auth-providers
|
|
2
|
+
[](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;
|
|
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"}
|
package/dist/auth/browserAuth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 -
|
|
476
|
-
// (for SSH/remote sessions or when browser should
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"saml2TokenExchange.d.ts","sourceRoot":"","sources":["../../src/auth/saml2TokenExchange.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAGxD,MAAM,WAAW,0BAA0B;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD,wBAAsB,qBAAqB,CACzC,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,MAAM,CAAC,EAAE,OAAO,GACf,OAAO,CAAC,0BAA0B,CAAC,
|
|
1
|
+
{"version":3,"file":"saml2TokenExchange.d.ts","sourceRoot":"","sources":["../../src/auth/saml2TokenExchange.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAGxD,MAAM,WAAW,0BAA0B;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD,wBAAsB,qBAAqB,CACzC,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,MAAM,CAAC,EAAE,OAAO,GACf,OAAO,CAAC,0BAA0B,CAAC,CAwCrC"}
|
|
@@ -25,7 +25,19 @@ async function exchangeSamlAssertion(samlResponse, tokenUrl, clientId, clientSec
|
|
|
25
25
|
if (clientId && clientSecret) {
|
|
26
26
|
headers.Authorization = `Basic ${toBasicAuth(clientId, clientSecret)}`;
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
let response;
|
|
29
|
+
try {
|
|
30
|
+
response = await axios_1.default.post(tokenUrl, params.toString(), { headers });
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (axios_1.default.isAxiosError(error)) {
|
|
34
|
+
logger?.error('[SAML] Token exchange failed', {
|
|
35
|
+
status: error.response?.status,
|
|
36
|
+
data: error.response?.data,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
29
41
|
const data = response.data;
|
|
30
42
|
if (!data?.access_token) {
|
|
31
43
|
throw new Error('Token response missing access_token');
|