@mcp-abap-adt/auth-providers 0.2.0 → 0.2.2
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 +33 -0
- package/README.md +7 -3
- package/dist/auth/browserAuth.d.ts.map +1 -1
- package/dist/auth/browserAuth.js +166 -22
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.2] - 2025-12-20
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Process Termination Cleanup**: OAuth callback server now properly cleans up when process is terminated
|
|
14
|
+
- Added `process.on('exit', 'SIGTERM', 'SIGINT', 'SIGHUP')` handlers to ensure server closes on process termination
|
|
15
|
+
- This fixes port leaks when MCP clients (like Cline) kill the stdio server before authentication completes
|
|
16
|
+
- Cleanup handlers are automatically removed after authentication completes to prevent memory leaks
|
|
17
|
+
- Ports are now properly freed even when process is forcefully terminated
|
|
18
|
+
|
|
19
|
+
## [0.2.1] - 2025-01-XX
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **Automatic Port Selection**: Browser auth server now automatically finds an available port if the requested port is in use
|
|
23
|
+
- When `startBrowserAuth()` is called with a port, it checks if the port is available
|
|
24
|
+
- If the port is busy, it automatically tries the next ports (up to 10 attempts)
|
|
25
|
+
- This prevents `EADDRINUSE` errors when multiple stdio servers run simultaneously
|
|
26
|
+
- Port selection happens before server startup, ensuring no conflicts
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **Server Port Cleanup**: Improved server shutdown to ensure ports are properly freed after authentication completes
|
|
30
|
+
- Added `keepAliveTimeout = 0` and `headersTimeout = 0` to prevent connections from staying open
|
|
31
|
+
- Added `closeAllConnections()` calls before `server.close()` to ensure all connections are closed
|
|
32
|
+
- Server now waits for HTTP response to finish before closing to prevent connection leaks
|
|
33
|
+
- Added proper error handling for browser open failures to ensure server is closed
|
|
34
|
+
- Server now properly closes in all error scenarios (timeout, browser open failure, callback errors)
|
|
35
|
+
- This prevents ports from remaining occupied after authentication completes or server shutdown
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
- **Port Selection Logic**: `startBrowserAuth()` now uses `findAvailablePort()` to automatically select a free port
|
|
39
|
+
- Default behavior: tries requested port first, then tries next ports if busy
|
|
40
|
+
- Port range: tries up to 10 consecutive ports starting from the requested port
|
|
41
|
+
- Logs when a different port is used (for debugging)
|
|
42
|
+
|
|
10
43
|
## [0.2.0] - 2025-12-19
|
|
11
44
|
|
|
12
45
|
### Added
|
package/README.md
CHANGED
|
@@ -173,10 +173,12 @@ import { BtpTokenProvider } from '@mcp-abap-adt/auth-providers';
|
|
|
173
173
|
import type { IAuthorizationConfig } from '@mcp-abap-adt/auth-broker';
|
|
174
174
|
|
|
175
175
|
// Create provider with default port (3001)
|
|
176
|
+
// The provider will automatically find an available port if 3001 is busy
|
|
176
177
|
const provider = new BtpTokenProvider();
|
|
177
178
|
|
|
178
|
-
// Or specify custom port for OAuth callback server
|
|
179
|
-
|
|
179
|
+
// Or specify custom port for OAuth callback server
|
|
180
|
+
// If the port is busy, the provider will automatically try the next ports
|
|
181
|
+
const providerWithCustomPort = new BtpTokenProvider(4002);
|
|
180
182
|
|
|
181
183
|
const authConfig: IAuthorizationConfig = {
|
|
182
184
|
uaaUrl: 'https://...authentication...hana.ondemand.com',
|
|
@@ -196,7 +198,9 @@ const result = await provider.getConnectionConfig(authConfig, {
|
|
|
196
198
|
// result.refreshToken contains refresh token (if browser flow was used)
|
|
197
199
|
```
|
|
198
200
|
|
|
199
|
-
**Note**: The `browserAuthPort` parameter (default: 3001) configures the OAuth callback server port.
|
|
201
|
+
**Note**: The `browserAuthPort` parameter (default: 3001) configures the OAuth callback server port. The provider automatically finds an available port if the requested port is in use, preventing `EADDRINUSE` errors when multiple instances run simultaneously. The server properly closes all connections and frees the port after authentication completes, ensuring no lingering port occupation.
|
|
202
|
+
|
|
203
|
+
**Process Termination Handling**: The OAuth callback server registers cleanup handlers for `SIGTERM`, `SIGINT`, `SIGHUP`, and `exit` signals. This ensures ports are properly freed even when MCP clients (like Cline) terminate the process before authentication completes. This is especially important for stdio servers where the client may kill the process at any time.
|
|
200
204
|
|
|
201
205
|
### Token Validation
|
|
202
206
|
|
|
@@ -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;AAOH,OAAO,KAAK,EAAE,oBAAoB,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAyB9E;;;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,CAsCzD;AAyCD;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CACpC,UAAU,EAAE,oBAAoB,EAChC,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,CAkazD"}
|
package/dist/auth/browserAuth.js
CHANGED
|
@@ -42,6 +42,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
42
42
|
exports.exchangeCodeForToken = exchangeCodeForToken;
|
|
43
43
|
exports.startBrowserAuth = startBrowserAuth;
|
|
44
44
|
const http = __importStar(require("http"));
|
|
45
|
+
const net = __importStar(require("net"));
|
|
45
46
|
const child_process = __importStar(require("child_process"));
|
|
46
47
|
const express_1 = __importDefault(require("express"));
|
|
47
48
|
const axios_1 = __importDefault(require("axios"));
|
|
@@ -111,6 +112,32 @@ function isDebugEnabled() {
|
|
|
111
112
|
process.env.DEBUG?.includes('auth-providers') === true ||
|
|
112
113
|
process.env.DEBUG?.includes('browser-auth') === true;
|
|
113
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Check if a port is available
|
|
117
|
+
*/
|
|
118
|
+
function isPortAvailable(port) {
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
const server = net.createServer();
|
|
121
|
+
server.listen(port, () => {
|
|
122
|
+
server.once('close', () => resolve(true));
|
|
123
|
+
server.close();
|
|
124
|
+
});
|
|
125
|
+
server.on('error', () => resolve(false));
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Find an available port starting from the given port
|
|
130
|
+
* Tries ports in range [startPort, startPort + maxAttempts)
|
|
131
|
+
*/
|
|
132
|
+
async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
133
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
134
|
+
const port = startPort + i;
|
|
135
|
+
if (await isPortAvailable(port)) {
|
|
136
|
+
return port;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
|
|
140
|
+
}
|
|
114
141
|
/**
|
|
115
142
|
* Start browser authentication flow
|
|
116
143
|
* @param authConfig Authorization configuration with UAA credentials
|
|
@@ -123,22 +150,76 @@ function isDebugEnabled() {
|
|
|
123
150
|
async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3001) {
|
|
124
151
|
// Use logger if provided, otherwise null (no logging)
|
|
125
152
|
const log = logger || null;
|
|
153
|
+
// Find available port (try starting from requested port, then try next ports)
|
|
154
|
+
let actualPort;
|
|
155
|
+
try {
|
|
156
|
+
actualPort = await findAvailablePort(port, 10);
|
|
157
|
+
if (actualPort !== port) {
|
|
158
|
+
log?.debug(`Port ${port} is in use, using port ${actualPort} instead`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
throw new Error(`Failed to find available port starting from ${port}: ${error instanceof Error ? error.message : String(error)}`);
|
|
163
|
+
}
|
|
126
164
|
return new Promise((originalResolve, originalReject) => {
|
|
127
165
|
let timeoutId = null;
|
|
166
|
+
let cleanupDone = false;
|
|
167
|
+
const app = (0, express_1.default)();
|
|
168
|
+
const server = http.createServer(app);
|
|
169
|
+
// Disable keep-alive to ensure connections close immediately
|
|
170
|
+
server.keepAliveTimeout = 0;
|
|
171
|
+
server.headersTimeout = 0;
|
|
172
|
+
const PORT = actualPort;
|
|
173
|
+
let serverInstance = null;
|
|
174
|
+
// Cleanup function to ensure server is closed on process termination
|
|
175
|
+
const cleanup = () => {
|
|
176
|
+
if (cleanupDone)
|
|
177
|
+
return;
|
|
178
|
+
cleanupDone = true;
|
|
179
|
+
log?.debug(`Cleaning up OAuth callback server on port ${PORT}`);
|
|
180
|
+
if (timeoutId) {
|
|
181
|
+
clearTimeout(timeoutId);
|
|
182
|
+
timeoutId = null;
|
|
183
|
+
}
|
|
184
|
+
if (server) {
|
|
185
|
+
try {
|
|
186
|
+
if (typeof server.closeAllConnections === 'function') {
|
|
187
|
+
server.closeAllConnections();
|
|
188
|
+
}
|
|
189
|
+
server.close(() => {
|
|
190
|
+
log?.debug(`OAuth server closed during cleanup, port ${PORT} freed`);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
// Ignore errors during cleanup
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
// Remove cleanup listeners to prevent memory leaks
|
|
199
|
+
const removeCleanupListeners = () => {
|
|
200
|
+
process.removeListener('exit', cleanup);
|
|
201
|
+
process.removeListener('SIGTERM', cleanup);
|
|
202
|
+
process.removeListener('SIGINT', cleanup);
|
|
203
|
+
process.removeListener('SIGHUP', cleanup);
|
|
204
|
+
};
|
|
128
205
|
const resolve = (value) => {
|
|
129
206
|
if (timeoutId)
|
|
130
207
|
clearTimeout(timeoutId);
|
|
208
|
+
removeCleanupListeners();
|
|
131
209
|
originalResolve(value);
|
|
132
210
|
};
|
|
133
211
|
const reject = (reason) => {
|
|
134
212
|
if (timeoutId)
|
|
135
213
|
clearTimeout(timeoutId);
|
|
214
|
+
removeCleanupListeners();
|
|
136
215
|
originalReject(reason);
|
|
137
216
|
};
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
217
|
+
// Register cleanup handlers for process termination
|
|
218
|
+
// This ensures port is freed when Cline or other clients kill the process
|
|
219
|
+
process.once('exit', cleanup);
|
|
220
|
+
process.once('SIGTERM', cleanup);
|
|
221
|
+
process.once('SIGINT', cleanup);
|
|
222
|
+
process.once('SIGHUP', cleanup);
|
|
142
223
|
const authorizationUrl = getJwtAuthorizationUrl(authConfig, PORT);
|
|
143
224
|
// OAuth2 callback handler
|
|
144
225
|
app.get('/callback', async (req, res) => {
|
|
@@ -284,36 +365,75 @@ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3
|
|
|
284
365
|
</div>
|
|
285
366
|
</body>
|
|
286
367
|
</html>`;
|
|
287
|
-
// Send success page first
|
|
368
|
+
// Send success page first and ensure response is finished
|
|
288
369
|
res.send(html);
|
|
370
|
+
// Wait for response to finish before closing server
|
|
371
|
+
res.on('finish', () => {
|
|
372
|
+
// Response finished, now we can safely close server
|
|
373
|
+
});
|
|
289
374
|
// Exchange code for tokens and close server
|
|
290
375
|
try {
|
|
291
376
|
const tokens = await exchangeCodeForToken(authConfig, code, PORT, log);
|
|
292
377
|
log?.info(`Tokens received: accessToken(${tokens.accessToken?.length || 0} chars), refreshToken(${tokens.refreshToken?.length || 0} chars)`);
|
|
293
|
-
// Close all connections
|
|
378
|
+
// Close all connections first to ensure port is freed
|
|
294
379
|
if (typeof server.closeAllConnections === 'function') {
|
|
295
380
|
server.closeAllConnections();
|
|
296
381
|
}
|
|
297
|
-
server
|
|
298
|
-
|
|
299
|
-
|
|
382
|
+
// Close server after response is finished
|
|
383
|
+
// This ensures the response connection is closed before server.close()
|
|
384
|
+
const closeServer = () => {
|
|
385
|
+
server.close(() => {
|
|
386
|
+
// Server closed - port should be freed
|
|
387
|
+
log?.debug(`Server closed, port ${PORT} should be freed`);
|
|
388
|
+
});
|
|
389
|
+
};
|
|
390
|
+
if (res.finished) {
|
|
391
|
+
// Response already finished, close immediately
|
|
392
|
+
closeServer();
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
// Wait for response to finish
|
|
396
|
+
res.once('finish', closeServer);
|
|
397
|
+
}
|
|
300
398
|
resolve(tokens);
|
|
301
399
|
}
|
|
302
400
|
catch (error) {
|
|
303
401
|
if (typeof server.closeAllConnections === 'function') {
|
|
304
402
|
server.closeAllConnections();
|
|
305
403
|
}
|
|
306
|
-
server.close(
|
|
307
|
-
|
|
308
|
-
|
|
404
|
+
// Use setTimeout to ensure connections are closed before server.close()
|
|
405
|
+
setTimeout(() => {
|
|
406
|
+
server.close(() => {
|
|
407
|
+
// Server closed on error - port should be freed
|
|
408
|
+
log?.debug(`Server closed on error, port ${PORT} should be freed`);
|
|
409
|
+
});
|
|
410
|
+
}, 100);
|
|
309
411
|
reject(error);
|
|
310
412
|
}
|
|
311
413
|
}
|
|
312
414
|
catch (error) {
|
|
313
415
|
res.status(500).send('Error processing authentication');
|
|
314
|
-
server.
|
|
315
|
-
|
|
316
|
-
}
|
|
416
|
+
if (typeof server.closeAllConnections === 'function') {
|
|
417
|
+
server.closeAllConnections();
|
|
418
|
+
}
|
|
419
|
+
// Use setTimeout to ensure connections are closed before server.close()
|
|
420
|
+
setTimeout(() => {
|
|
421
|
+
server.close(() => {
|
|
422
|
+
// Server closed on error - port should be freed
|
|
423
|
+
log?.debug(`Server closed on error, port ${PORT} should be freed`);
|
|
424
|
+
});
|
|
425
|
+
}, 100);
|
|
426
|
+
reject(error);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
// Handle server errors (e.g., EADDRINUSE)
|
|
430
|
+
server.on('error', (error) => {
|
|
431
|
+
if (error.code === 'EADDRINUSE') {
|
|
432
|
+
log?.error(`Port ${PORT} is already in use. This should not happen after port check.`);
|
|
433
|
+
reject(new Error(`Port ${PORT} is already in use. Please try again or specify a different port.`));
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
log?.error(`Server error: ${error.message}`);
|
|
317
437
|
reject(error);
|
|
318
438
|
}
|
|
319
439
|
});
|
|
@@ -324,9 +444,16 @@ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3
|
|
|
324
444
|
// For 'none' browser, don't wait for callback - throw error immediately
|
|
325
445
|
// User must open browser manually and we can't wait for callback in automated tests
|
|
326
446
|
if (serverInstance) {
|
|
327
|
-
server.
|
|
328
|
-
|
|
329
|
-
}
|
|
447
|
+
if (typeof server.closeAllConnections === 'function') {
|
|
448
|
+
server.closeAllConnections();
|
|
449
|
+
}
|
|
450
|
+
// Use setTimeout to ensure connections are closed before server.close()
|
|
451
|
+
setTimeout(() => {
|
|
452
|
+
server.close(() => {
|
|
453
|
+
// Server closed - port should be freed
|
|
454
|
+
log?.debug(`Server closed (browser=none), port ${PORT} should be freed`);
|
|
455
|
+
});
|
|
456
|
+
}, 100);
|
|
330
457
|
}
|
|
331
458
|
reject(new Error(`Browser authentication required. Please open this URL manually: ${authorizationUrl}`));
|
|
332
459
|
return;
|
|
@@ -391,7 +518,17 @@ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3
|
|
|
391
518
|
}
|
|
392
519
|
}
|
|
393
520
|
catch (error) {
|
|
394
|
-
// If browser cannot be opened,
|
|
521
|
+
// If browser cannot be opened, close server and show URL
|
|
522
|
+
if (typeof server.closeAllConnections === 'function') {
|
|
523
|
+
server.closeAllConnections();
|
|
524
|
+
}
|
|
525
|
+
// Use setTimeout to ensure connections are closed before server.close()
|
|
526
|
+
setTimeout(() => {
|
|
527
|
+
server.close(() => {
|
|
528
|
+
// Server closed on browser open error - port should be freed
|
|
529
|
+
log?.debug(`Server closed on browser open error, port ${PORT} should be freed`);
|
|
530
|
+
});
|
|
531
|
+
}, 100);
|
|
395
532
|
log?.error(`❌ Failed to open browser: ${error?.message || String(error)}. Please open manually: ${authorizationUrl}`, { error: error?.message || String(error), url: authorizationUrl });
|
|
396
533
|
log?.info(`🔗 Open in browser: ${authorizationUrl}`, { url: authorizationUrl });
|
|
397
534
|
// Throw error so consumer can distinguish this from "service key missing" error
|
|
@@ -402,9 +539,16 @@ async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3
|
|
|
402
539
|
// Timeout after 5 minutes
|
|
403
540
|
timeoutId = setTimeout(() => {
|
|
404
541
|
if (serverInstance) {
|
|
405
|
-
server.
|
|
406
|
-
|
|
407
|
-
}
|
|
542
|
+
if (typeof server.closeAllConnections === 'function') {
|
|
543
|
+
server.closeAllConnections();
|
|
544
|
+
}
|
|
545
|
+
// Use setTimeout to ensure connections are closed before server.close()
|
|
546
|
+
setTimeout(() => {
|
|
547
|
+
server.close(() => {
|
|
548
|
+
// Server closed on timeout - port should be freed
|
|
549
|
+
log?.debug(`Server closed on timeout, port ${PORT} should be freed`);
|
|
550
|
+
});
|
|
551
|
+
}, 100);
|
|
408
552
|
reject(new Error('Authentication timeout. Process aborted.'));
|
|
409
553
|
}
|
|
410
554
|
}, 5 * 60 * 1000);
|
package/package.json
CHANGED