@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.
- package/CHANGELOG.md +24 -0
- package/README.md +7 -3
- package/dist/auth/browserAuth.d.ts +7 -8
- package/dist/auth/browserAuth.d.ts.map +1 -1
- package/dist/auth/browserAuth.js +149 -498
- package/dist/auth/callbackServer.d.ts +40 -0
- package/dist/auth/callbackServer.d.ts.map +1 -0
- package/dist/auth/callbackServer.js +322 -0
- package/dist/auth/oidcBrowserAuth.d.ts +10 -2
- package/dist/auth/oidcBrowserAuth.d.ts.map +1 -1
- package/dist/auth/oidcBrowserAuth.js +37 -37
- package/dist/auth/saml2Auth.d.ts +8 -1
- package/dist/auth/saml2Auth.d.ts.map +1 -1
- package/dist/auth/saml2Auth.js +42 -42
- package/dist/providers/AuthorizationCodeProvider.d.ts.map +1 -1
- package/dist/providers/AuthorizationCodeProvider.js +11 -12
- package/package.json +4 -4
package/dist/auth/browserAuth.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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: ${
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
}
|