@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.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Scoped callback servers for interactive authorization flows.
3
+ *
4
+ * One owner, one release point. The socket belongs to the scope, not to the
5
+ * promise a caller happens to be awaiting: it is released on the first terminal
6
+ * outcome — the body returning or throwing, an explicit failure, the timeout, or
7
+ * an abort — and the factory settles only once it is actually free.
8
+ *
9
+ * See `docs/superpowers/specs/2026-07-28-callback-server-contract-design.md`.
10
+ */
11
+ import type { CallbackServerFactory, ICallbackServerHandle, ICallbackServerOptions } from '@mcp-abap-adt/interfaces';
12
+ import express from 'express';
13
+ /**
14
+ * How a route reports an outcome. Settling is deferred until the response has
15
+ * actually flushed — `res.send()` returning does not mean the bytes have left,
16
+ * and settling earlier races the shutdown against the page being delivered.
17
+ */
18
+ export interface Settle<TResult> {
19
+ /** The callback delivered a result. Does not end the scope by itself. */
20
+ ok(value: TResult, res?: express.Response): void;
21
+ /** The callback reported a failure. Ends the scope. */
22
+ err(error: Error, res?: express.Response): void;
23
+ }
24
+ export type RouteSetup<TResult> = (app: express.Express, settle: Settle<TResult>) => void;
25
+ /**
26
+ * Owns the socket for the duration of `use`.
27
+ *
28
+ * Every flow registers its routes through `routes` and reports outcomes through
29
+ * `settle`; nothing else touches the internal promise, which is what keeps the
30
+ * "settles exactly once" guarantee in one place.
31
+ */
32
+ export declare function runCallbackScope<TResult, TReturn>(options: ICallbackServerOptions, routes: RouteSetup<TResult>, use: (server: ICallbackServerHandle<TResult>) => Promise<TReturn>): Promise<TReturn>;
33
+ /**
34
+ * Callback server for the UAA authorization-code flow.
35
+ *
36
+ * Delivers the authorization code. The paste form and `/submit` are kept for
37
+ * the case where the browser runs on another machine.
38
+ */
39
+ export declare const withBrowserCallbackServer: CallbackServerFactory<string>;
40
+ //# sourceMappingURL=callbackServer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"callbackServer.d.ts","sourceRoot":"","sources":["../../src/auth/callbackServer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EACV,qBAAqB,EACrB,qBAAqB,EACrB,sBAAsB,EACvB,MAAM,0BAA0B,CAAC;AAClC,OAAO,OAAO,MAAM,SAAS,CAAC;AAS9B;;;;GAIG;AACH,MAAM,WAAW,MAAM,CAAC,OAAO;IAC7B,yEAAyE;IACzE,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IACjD,uDAAuD;IACvD,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,CAAC,EAAE,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;CACjD;AAED,MAAM,MAAM,UAAU,CAAC,OAAO,IAAI,CAChC,GAAG,EAAE,OAAO,CAAC,OAAO,EACpB,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC,KACpB,IAAI,CAAC;AAqBV;;;;;;GAMG;AACH,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,OAAO,EACrD,OAAO,EAAE,sBAAsB,EAC/B,MAAM,EAAE,UAAU,CAAC,OAAO,CAAC,EAC3B,GAAG,EAAE,CAAC,MAAM,EAAE,qBAAqB,CAAC,OAAO,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,GAChE,OAAO,CAAC,OAAO,CAAC,CAuLlB;AAyCD;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,EAAE,qBAAqB,CAAC,MAAM,CA4DjE,CAAC"}
@@ -0,0 +1,322 @@
1
+ "use strict";
2
+ /**
3
+ * Scoped callback servers for interactive authorization flows.
4
+ *
5
+ * One owner, one release point. The socket belongs to the scope, not to the
6
+ * promise a caller happens to be awaiting: it is released on the first terminal
7
+ * outcome — the body returning or throwing, an explicit failure, the timeout, or
8
+ * an abort — and the factory settles only once it is actually free.
9
+ *
10
+ * See `docs/superpowers/specs/2026-07-28-callback-server-contract-design.md`.
11
+ */
12
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ var desc = Object.getOwnPropertyDescriptor(m, k);
15
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
16
+ desc = { enumerable: true, get: function() { return m[k]; } };
17
+ }
18
+ Object.defineProperty(o, k2, desc);
19
+ }) : (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ o[k2] = m[k];
22
+ }));
23
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
24
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
25
+ }) : function(o, v) {
26
+ o["default"] = v;
27
+ });
28
+ var __importStar = (this && this.__importStar) || (function () {
29
+ var ownKeys = function(o) {
30
+ ownKeys = Object.getOwnPropertyNames || function (o) {
31
+ var ar = [];
32
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
33
+ return ar;
34
+ };
35
+ return ownKeys(o);
36
+ };
37
+ return function (mod) {
38
+ if (mod && mod.__esModule) return mod;
39
+ var result = {};
40
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
41
+ __setModuleDefault(result, mod);
42
+ return result;
43
+ };
44
+ })();
45
+ var __importDefault = (this && this.__importDefault) || function (mod) {
46
+ return (mod && mod.__esModule) ? mod : { "default": mod };
47
+ };
48
+ Object.defineProperty(exports, "__esModule", { value: true });
49
+ exports.withBrowserCallbackServer = void 0;
50
+ exports.runCallbackScope = runCallbackScope;
51
+ const http = __importStar(require("node:http"));
52
+ const express_1 = __importDefault(require("express"));
53
+ const browserAuth_1 = require("./browserAuth");
54
+ /** Node's `setTimeout` takes a 32-bit signed delay; above this it fires in 1 ms. */
55
+ const MAX_TIMEOUT_MS = 2_147_483_647;
56
+ /** How long shutdown waits for `close` before destroying what is left. */
57
+ const SHUTDOWN_GRACE_MS = 500;
58
+ function validate(options) {
59
+ const { port, timeoutMs } = options;
60
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
61
+ throw new Error(`Invalid callback server port: ${String(port)}. Must be an integer in 1..65535.`);
62
+ }
63
+ if (!Number.isFinite(timeoutMs) ||
64
+ timeoutMs <= 0 ||
65
+ timeoutMs > MAX_TIMEOUT_MS) {
66
+ throw new Error(`Invalid callback server timeoutMs: ${String(timeoutMs)}. ` +
67
+ `Must be finite and within 1..${MAX_TIMEOUT_MS}.`);
68
+ }
69
+ }
70
+ /**
71
+ * Owns the socket for the duration of `use`.
72
+ *
73
+ * Every flow registers its routes through `routes` and reports outcomes through
74
+ * `settle`; nothing else touches the internal promise, which is what keeps the
75
+ * "settles exactly once" guarantee in one place.
76
+ */
77
+ async function runCallbackScope(options, routes, use) {
78
+ validate(options);
79
+ if (options.signal?.aborted) {
80
+ throw new Error('Callback server aborted before it started');
81
+ }
82
+ const app = (0, express_1.default)();
83
+ const server = http.createServer(app);
84
+ const sockets = new Set();
85
+ server.on('connection', (socket) => {
86
+ sockets.add(socket);
87
+ socket.on('close', () => sockets.delete(socket));
88
+ });
89
+ let resultSettled = false;
90
+ let resolveResult;
91
+ let rejectResult;
92
+ const resultPromise = new Promise((res, rej) => {
93
+ resolveResult = res;
94
+ rejectResult = rej;
95
+ });
96
+ // Marked handled at creation: a body may create this promise and walk away,
97
+ // and rejecting it at scope end would otherwise raise unhandledRejection.
98
+ void resultPromise.catch(() => undefined);
99
+ let scopeSettled = false;
100
+ let resolveScope;
101
+ let rejectScope;
102
+ const scopePromise = new Promise((res, rej) => {
103
+ resolveScope = res;
104
+ rejectScope = rej;
105
+ });
106
+ let timer = null;
107
+ let alive = false;
108
+ const settleResult = (outcome) => {
109
+ if (resultSettled)
110
+ return;
111
+ resultSettled = true;
112
+ if ('value' in outcome)
113
+ resolveResult(outcome.value);
114
+ else
115
+ rejectResult(outcome.error);
116
+ };
117
+ /** The one place a scope ends. Everything after the first call is a no-op. */
118
+ const endScope = (outcome) => {
119
+ if (scopeSettled)
120
+ return;
121
+ scopeSettled = true;
122
+ alive = false;
123
+ if (timer) {
124
+ clearTimeout(timer);
125
+ timer = null;
126
+ }
127
+ options.signal?.removeEventListener('abort', onAbort);
128
+ settleResult({
129
+ error: new Error('Callback server closed before a result arrived'),
130
+ });
131
+ void shutdown().then(() => {
132
+ if ('value' in outcome)
133
+ resolveScope(outcome.value);
134
+ else
135
+ rejectScope(outcome.error);
136
+ });
137
+ };
138
+ function onAbort() {
139
+ endScope({ error: new Error('Callback server aborted') });
140
+ }
141
+ /**
142
+ * Stop accepting, end idle connections, wait for `close` under a grace, then
143
+ * destroy whatever is left. `closeAllConnections()` is deliberately not used
144
+ * in the first step: it destroys active connections too, and the one carrying
145
+ * the success page is active.
146
+ */
147
+ function shutdown() {
148
+ return new Promise((done) => {
149
+ let finished = false;
150
+ const finish = () => {
151
+ if (finished)
152
+ return;
153
+ finished = true;
154
+ done();
155
+ };
156
+ // `close()` alone, first. From Node 19 it ends idle connections itself,
157
+ // and it does so gracefully — the client still reads what was written.
158
+ // `closeIdleConnections()` destroys instead, which cuts the success page
159
+ // off mid-read: `finish` on the response means "handed to the OS", not
160
+ // "read by the client".
161
+ server.close(() => finish());
162
+ setTimeout(() => {
163
+ if (finished)
164
+ return;
165
+ // Grace expired — on Node 18.x `close()` does not end idle connections,
166
+ // and an active one may simply be stuck. Force it, bounded.
167
+ server.closeIdleConnections?.();
168
+ server.closeAllConnections?.();
169
+ for (const socket of sockets)
170
+ socket.destroy();
171
+ finish();
172
+ }, SHUTDOWN_GRACE_MS).unref?.();
173
+ });
174
+ }
175
+ /**
176
+ * Settle only once the response has actually flushed, so shutdown cannot cut
177
+ * it off.
178
+ *
179
+ * The check is `writableFinished`, not `writableEnded`: the latter is true as
180
+ * soon as `end()` has been called and says nothing about the data having
181
+ * left. Measured on Node 25 with a paused client — an 800-byte body reports
182
+ * both flags true at once, but a 20 MB body reports `writableEnded` true and
183
+ * `writableFinished` false, with `finish` arriving 456 ms later. Keying off
184
+ * `writableEnded` therefore made this deferral a no-op on the very path it
185
+ * exists for.
186
+ */
187
+ const afterFlush = (res, then) => {
188
+ if (!res || res.writableFinished) {
189
+ then();
190
+ return;
191
+ }
192
+ res.once('finish', then);
193
+ res.once('close', then);
194
+ };
195
+ const settle = {
196
+ ok(value, res) {
197
+ afterFlush(res, () => settleResult({ value }));
198
+ },
199
+ err(error, res) {
200
+ afterFlush(res, () => {
201
+ settleResult({ error });
202
+ endScope({ error });
203
+ });
204
+ },
205
+ };
206
+ routes(app, settle);
207
+ options.signal?.addEventListener('abort', onAbort, { once: true });
208
+ const handle = {
209
+ port: options.port,
210
+ redirectUri: `http://localhost:${options.port}/callback`,
211
+ waitForResult: () => alive
212
+ ? resultPromise
213
+ : Promise.reject(new Error('Callback server scope has ended')),
214
+ // Silent no-op once the scope has ended: this is called fire-and-forget
215
+ // from a browser launcher's .catch(), and a late rejection must not become
216
+ // a fresh unhandled rejection.
217
+ fail: (error) => {
218
+ if (!alive)
219
+ return;
220
+ settleResult({ error });
221
+ endScope({ error });
222
+ },
223
+ };
224
+ server.once('error', (error) => {
225
+ endScope({ error });
226
+ });
227
+ server.listen(options.port, () => {
228
+ if (scopeSettled) {
229
+ // Aborted while binding.
230
+ void shutdown();
231
+ return;
232
+ }
233
+ alive = true;
234
+ timer = setTimeout(() => {
235
+ endScope({
236
+ error: new Error(`Authentication timeout after ${options.timeoutMs / 1000} seconds. Please try again.`),
237
+ });
238
+ }, options.timeoutMs);
239
+ void use(handle).then((value) => endScope({ value }), (error) => endScope({ error }));
240
+ });
241
+ return await scopePromise;
242
+ }
243
+ const successHtml = `<!DOCTYPE html>
244
+ <html lang="en"><head><meta charset="UTF-8">
245
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
246
+ <title>SAP BTP Authentication</title>
247
+ <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>
248
+ </head><body><div class="container"><div class="success-icon">✓</div>
249
+ <h1>Authentication Successful!</h1>
250
+ <p>You have successfully authenticated with SAP BTP. You can close this window.</p>
251
+ </div></body></html>`;
252
+ const errorHtml = (message) => `<!DOCTYPE html>
253
+ <html lang="en"><head><meta charset="UTF-8">
254
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
255
+ <title>Authentication Error</title>
256
+ <style>body{font-family:'Segoe UI',Tahoma,sans-serif;text-align:center;padding:50px 20px;background:linear-gradient(135deg,#dc2626,#ef4444);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}.error-icon{font-size:4rem;margin-bottom:20px;color:#fbbf24}h1{font-weight:300}</style>
257
+ </head><body><div class="container"><div class="error-icon">✗</div>
258
+ <h1>Authentication Failed</h1>
259
+ <p>${message}</p>
260
+ <p>Please check your service key configuration and try again.</p>
261
+ </div></body></html>`;
262
+ // Manual paste form (GET /). Used when the automatic localhost callback cannot
263
+ // reach this server (browser on another machine). Accepts a bare code or a full
264
+ // redirected URL; re-renders with a message on a bad paste.
265
+ const pasteFormHtml = (message) => `<!DOCTYPE html>
266
+ <html lang="en"><head><meta charset="UTF-8">
267
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
268
+ <title>SAP BTP Authentication — paste code</title>
269
+ <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>
270
+ </head><body><div class="container">
271
+ <h1>Paste authorization code</h1>
272
+ ${message ? `<p class="msg">${message}</p>` : ''}
273
+ <p>After signing in, copy the <code>code</code> from your browser's address bar
274
+ (or paste the whole redirected URL) and submit it here.</p>
275
+ <form action="/submit" method="get">
276
+ <input name="input" autofocus placeholder="code=... or http://localhost/callback?code=..." />
277
+ <button type="submit">Submit</button>
278
+ </form></div></body></html>`;
279
+ /**
280
+ * Callback server for the UAA authorization-code flow.
281
+ *
282
+ * Delivers the authorization code. The paste form and `/submit` are kept for
283
+ * the case where the browser runs on another machine.
284
+ */
285
+ const withBrowserCallbackServer = (options, use) => runCallbackScope(options, (app, settle) => {
286
+ app.get('/callback', (req, res) => {
287
+ const { error, error_description, error_uri } = req.query;
288
+ if (error) {
289
+ const message = error_description
290
+ ? `${String(error)}: ${String(error_description)}`
291
+ : String(error);
292
+ res.status(400).send(errorHtml(message));
293
+ settle.err(new Error(`OAuth2 authentication failed: ${message}` +
294
+ (error_uri ? ` (${String(error_uri)})` : '')), res);
295
+ return;
296
+ }
297
+ const { code } = req.query;
298
+ if (!code || typeof code !== 'string') {
299
+ res.status(400).send('Error: Authorization code missing');
300
+ settle.err(new Error('Authorization code missing'), res);
301
+ return;
302
+ }
303
+ res.send(successHtml);
304
+ settle.ok(code, res);
305
+ });
306
+ app.get('/', (_req, res) => {
307
+ res.send(pasteFormHtml());
308
+ });
309
+ app.get('/submit', (req, res) => {
310
+ const raw = req.query.input ?? req.query.code;
311
+ const code = typeof raw === 'string' ? (0, browserAuth_1.extractCode)(raw) : null;
312
+ if (!code) {
313
+ res
314
+ .status(400)
315
+ .send(pasteFormHtml('Could not read an authorization code from that input. Try again.'));
316
+ return;
317
+ }
318
+ res.send(successHtml);
319
+ settle.ok(code, res);
320
+ });
321
+ }, use);
322
+ exports.withBrowserCallbackServer = withBrowserCallbackServer;
@@ -2,8 +2,16 @@
2
2
  * OIDC browser authorization code flow (capture code)
3
3
  */
4
4
  import type { ILogger } from '@mcp-abap-adt/interfaces';
5
- export declare function startOidcBrowserAuth(authorizationUrl: string, browser: string, logger?: ILogger, port?: number): Promise<{
5
+ export interface OidcCallbackResult {
6
6
  code: string;
7
7
  state?: string;
8
- }>;
8
+ }
9
+ /**
10
+ * OIDC browser login.
11
+ *
12
+ * The callback socket belongs to the scope: it is released when the scope ends,
13
+ * whatever ends it. Before this, the flow had no timeout at all — an abandoned
14
+ * login never settled and the port was held for the life of the process.
15
+ */
16
+ export declare function startOidcBrowserAuth(authorizationUrl: string, browser: string, logger?: ILogger, port?: number, timeoutMs?: number): Promise<OidcCallbackResult>;
9
17
  //# sourceMappingURL=oidcBrowserAuth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"oidcBrowserAuth.d.ts","sourceRoot":"","sources":["../../src/auth/oidcBrowserAuth.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAqExD,wBAAsB,oBAAoB,CACxC,gBAAgB,EAAE,MAAM,EACxB,OAAO,EAAE,MAAM,EACf,MAAM,CAAC,EAAE,OAAO,EAChB,IAAI,GAAE,MAAa,GAClB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA2C3C"}
1
+ {"version":3,"file":"oidcBrowserAuth.d.ts","sourceRoot":"","sources":["../../src/auth/oidcBrowserAuth.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,KAAK,EAIV,OAAO,EACR,MAAM,0BAA0B,CAAC;AAqElC,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA+BD;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,gBAAgB,EAAE,MAAM,EACxB,OAAO,EAAE,MAAM,EACf,MAAM,CAAC,EAAE,OAAO,EAChB,IAAI,GAAE,MAAa,EACnB,SAAS,GAAE,MAAkB,GAC5B,OAAO,CAAC,kBAAkB,CAAC,CAyB7B"}
@@ -35,14 +35,10 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  return result;
36
36
  };
37
37
  })();
38
- var __importDefault = (this && this.__importDefault) || function (mod) {
39
- return (mod && mod.__esModule) ? mod : { "default": mod };
40
- };
41
38
  Object.defineProperty(exports, "__esModule", { value: true });
42
39
  exports.startOidcBrowserAuth = startOidcBrowserAuth;
43
- const http = __importStar(require("node:http"));
44
40
  const net = __importStar(require("node:net"));
45
- const express_1 = __importDefault(require("express"));
41
+ const callbackServer_1 = require("./callbackServer");
46
42
  const BROWSER_MAP = {
47
43
  chrome: 'chrome',
48
44
  edge: 'msedge',
@@ -103,42 +99,46 @@ async function openBrowserUrl(authorizationUrl, browser, logger) {
103
99
  logger?.info('[OIDC] Open URL manually', { authorizationUrl });
104
100
  }
105
101
  }
106
- async function startOidcBrowserAuth(authorizationUrl, browser, logger, port = 3001) {
102
+ const withOidcCallbackServer = (options, use) => (0, callbackServer_1.runCallbackScope)(options, (app, settle) => {
103
+ app.get('/callback', (req, res) => {
104
+ const code = req.query.code;
105
+ const state = req.query.state;
106
+ if (!code || typeof code !== 'string') {
107
+ res.status(400).send('Missing authorization code');
108
+ settle.err(new Error('Missing authorization code'), res);
109
+ return;
110
+ }
111
+ res
112
+ .status(200)
113
+ .send('Authentication complete. You can close this window.');
114
+ settle.ok({ code, state: typeof state === 'string' ? state : undefined }, res);
115
+ });
116
+ }, use);
117
+ /**
118
+ * OIDC browser login.
119
+ *
120
+ * The callback socket belongs to the scope: it is released when the scope ends,
121
+ * whatever ends it. Before this, the flow had no timeout at all — an abandoned
122
+ * login never settled and the port was held for the life of the process.
123
+ */
124
+ async function startOidcBrowserAuth(authorizationUrl, browser, logger, port = 3001, timeoutMs = 30 * 1000) {
125
+ // Pre-check kept for its message: AuthBroker matches /already in use/i to
126
+ // distinguish a busy port from other failures.
107
127
  const portAvailable = await isPortAvailable(port);
108
128
  if (!portAvailable) {
109
129
  throw new Error(`Port ${port} is already in use. Please specify a different port or free the port.`);
110
130
  }
111
- return new Promise((resolve, reject) => {
112
- const app = (0, express_1.default)();
113
- const server = http.createServer(app);
114
- server.keepAliveTimeout = 0;
115
- server.headersTimeout = 0;
116
- const PORT = port;
117
- let resolved = false;
118
- const cleanup = () => {
119
- if (resolved)
120
- return;
121
- resolved = true;
122
- server.close();
123
- };
124
- app.get('/callback', (req, res) => {
125
- const code = req.query.code;
126
- const state = req.query.state;
127
- if (!code || typeof code !== 'string') {
128
- res.status(400).send('Missing authorization code');
129
- cleanup();
130
- reject(new Error('Missing authorization code'));
131
- return;
132
- }
133
- res
134
- .status(200)
135
- .send('Authentication complete. You can close this window.');
136
- cleanup();
137
- resolve({ code, state: typeof state === 'string' ? state : undefined });
138
- });
139
- server.listen(PORT, async () => {
140
- logger?.info('[OIDC] Callback server listening', { port: PORT });
141
- await openBrowserUrl(authorizationUrl, browser, logger);
131
+ return await withOidcCallbackServer({ port, timeoutMs }, async (server) => {
132
+ logger?.info('[OIDC] Callback server listening', { port: server.port });
133
+ const waiting = server.waitForResult();
134
+ // Not awaited: a launcher that hangs must not delay the timeout or the
135
+ // release of the port.
136
+ void openBrowserUrl(authorizationUrl, browser, logger).catch((error) => {
137
+ logger?.warn('[OIDC] Failed to open browser', {
138
+ error: error instanceof Error ? error.message : String(error),
139
+ });
140
+ logger?.info('[OIDC] Open URL manually', { authorizationUrl });
142
141
  });
142
+ return await waiting;
143
143
  });
144
144
  }
@@ -10,6 +10,13 @@ export interface Saml2AuthConfig {
10
10
  authorizationUrl?: string;
11
11
  }
12
12
  export declare function buildSamlAuthorizationUrl(config: Saml2AuthConfig): string;
13
- export declare function startSamlBrowserAuth(config: Saml2AuthConfig, browser: string, logger?: ILogger, port?: number): Promise<string>;
13
+ /**
14
+ * SAML browser login.
15
+ *
16
+ * The callback socket belongs to the scope: it is released when the scope ends,
17
+ * whatever ends it. Before this, the flow had no timeout at all — an abandoned
18
+ * login never settled and the port was held for the life of the process.
19
+ */
20
+ export declare function startSamlBrowserAuth(config: Saml2AuthConfig, browser: string, logger?: ILogger, port?: number, timeoutMs?: number): Promise<string>;
14
21
  export declare function parseSamlNotOnOrAfter(samlResponse: string): number | undefined;
15
22
  //# sourceMappingURL=saml2Auth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"saml2Auth.d.ts","sourceRoot":"","sources":["../../src/auth/saml2Auth.ts"],"names":[],"mappings":"AAAA;;GAEG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAGxD,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AA8CD,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAazE;AA+CD,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,eAAe,EACvB,OAAO,EAAE,MAAM,EACf,MAAM,CAAC,EAAE,OAAO,EAChB,IAAI,GAAE,MAAa,GAClB,OAAO,CAAC,MAAM,CAAC,CA4DjB;AAED,wBAAgB,qBAAqB,CACnC,YAAY,EAAE,MAAM,GACnB,MAAM,GAAG,SAAS,CAYpB"}
1
+ {"version":3,"file":"saml2Auth.d.ts","sourceRoot":"","sources":["../../src/auth/saml2Auth.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,OAAO,KAAK,EAIV,OAAO,EACR,MAAM,0BAA0B,CAAC;AAIlC,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AA8CD,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAazE;AA8ED;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,eAAe,EACvB,OAAO,EAAE,MAAM,EACf,MAAM,CAAC,EAAE,OAAO,EAChB,IAAI,GAAE,MAAa,EACnB,SAAS,GAAE,MAAkB,GAC5B,OAAO,CAAC,MAAM,CAAC,CA2BjB;AAED,wBAAgB,qBAAqB,CACnC,YAAY,EAAE,MAAM,GACnB,MAAM,GAAG,SAAS,CAYpB"}
@@ -43,10 +43,10 @@ exports.buildSamlAuthorizationUrl = buildSamlAuthorizationUrl;
43
43
  exports.startSamlBrowserAuth = startSamlBrowserAuth;
44
44
  exports.parseSamlNotOnOrAfter = parseSamlNotOnOrAfter;
45
45
  const node_crypto_1 = require("node:crypto");
46
- const http = __importStar(require("node:http"));
47
46
  const net = __importStar(require("node:net"));
48
47
  const node_zlib_1 = require("node:zlib");
49
48
  const express_1 = __importDefault(require("express"));
49
+ const callbackServer_1 = require("./callbackServer");
50
50
  const BROWSER_MAP = {
51
51
  chrome: 'chrome',
52
52
  edge: 'msedge',
@@ -140,53 +140,53 @@ async function openBrowserUrl(authorizationUrl, browser, logger) {
140
140
  logger?.info('[SAML] Open URL manually', { authorizationUrl });
141
141
  }
142
142
  }
143
- async function startSamlBrowserAuth(config, browser, logger, port = 3001) {
143
+ const withSamlCallbackServer = (options, use) => (0, callbackServer_1.runCallbackScope)(options, (app, settle) => {
144
+ app.use(express_1.default.urlencoded({ extended: false, limit: '5mb' }));
145
+ const handle = (samlResponse, res) => {
146
+ res
147
+ .status(200)
148
+ .send('SAML authentication complete. You can close this window.');
149
+ if (typeof samlResponse === 'string' && samlResponse) {
150
+ settle.ok(samlResponse, res);
151
+ }
152
+ else {
153
+ settle.err(new Error('Missing SAMLResponse'), res);
154
+ }
155
+ };
156
+ app.post('/callback', (req, res) => {
157
+ handle(req.body?.SAMLResponse, res);
158
+ });
159
+ app.get('/callback', (req, res) => {
160
+ handle(req.query.SAMLResponse, res);
161
+ });
162
+ }, use);
163
+ /**
164
+ * SAML browser login.
165
+ *
166
+ * The callback socket belongs to the scope: it is released when the scope ends,
167
+ * whatever ends it. Before this, the flow had no timeout at all — an abandoned
168
+ * login never settled and the port was held for the life of the process.
169
+ */
170
+ async function startSamlBrowserAuth(config, browser, logger, port = 3001, timeoutMs = 30 * 1000) {
171
+ // Pre-check kept for its message: AuthBroker matches /already in use/i to
172
+ // distinguish a busy port from other failures.
144
173
  const portAvailable = await isPortAvailable(port);
145
174
  if (!portAvailable) {
146
175
  throw new Error(`Port ${port} is already in use. Please specify a different port or free the port.`);
147
176
  }
148
177
  const authorizationUrl = buildSamlAuthorizationUrl(config);
149
- return new Promise((resolve, reject) => {
150
- const app = (0, express_1.default)();
151
- app.use(express_1.default.urlencoded({ extended: false, limit: '5mb' }));
152
- const server = http.createServer(app);
153
- server.keepAliveTimeout = 0;
154
- server.headersTimeout = 0;
155
- const PORT = port;
156
- let resolved = false;
157
- const cleanup = () => {
158
- if (resolved)
159
- return;
160
- resolved = true;
161
- server.close();
162
- };
163
- const handleResponse = (samlResponse) => {
164
- if (!samlResponse) {
165
- cleanup();
166
- reject(new Error('Missing SAMLResponse'));
167
- return;
168
- }
169
- cleanup();
170
- resolve(samlResponse);
171
- };
172
- app.post('/callback', (req, res) => {
173
- const samlResponse = req.body?.SAMLResponse;
174
- res
175
- .status(200)
176
- .send('SAML authentication complete. You can close this window.');
177
- handleResponse(typeof samlResponse === 'string' ? samlResponse : undefined);
178
- });
179
- app.get('/callback', (req, res) => {
180
- const samlResponse = req.query.SAMLResponse;
181
- res
182
- .status(200)
183
- .send('SAML authentication complete. You can close this window.');
184
- handleResponse(typeof samlResponse === 'string' ? samlResponse : undefined);
185
- });
186
- server.listen(PORT, async () => {
187
- logger?.info('[SAML] Callback server listening', { port: PORT });
188
- await openBrowserUrl(authorizationUrl, browser, logger);
178
+ return await withSamlCallbackServer({ port, timeoutMs }, async (server) => {
179
+ logger?.info('[SAML] Callback server listening', { port: server.port });
180
+ const waiting = server.waitForResult();
181
+ // Not awaited: a launcher that hangs must not delay the timeout or the
182
+ // release of the port.
183
+ void openBrowserUrl(authorizationUrl, browser, logger).catch((error) => {
184
+ logger?.warn('[SAML] Failed to open browser', {
185
+ error: error instanceof Error ? error.message : String(error),
186
+ });
187
+ logger?.info('[SAML] Open URL manually', { authorizationUrl });
189
188
  });
189
+ return await waiting;
190
190
  });
191
191
  }
192
192
  function parseSamlNotOnOrAfter(samlResponse) {
@@ -1 +1 @@
1
- {"version":3,"file":"AuthorizationCodeProvider.d.ts","sourceRoot":"","sources":["../../src/providers/AuthorizationCodeProvider.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAEV,OAAO,EACP,YAAY,EACZ,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAIlC,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD,MAAM,WAAW,+BAA+B;IAE9C,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IAGrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAG1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IAGtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IAGtB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;;;;GAKG;AACH,qBAAa,yBAA0B,SAAQ,iBAAiB;IAC9D,OAAO,CAAC,MAAM,CAAkC;gBAEpC,MAAM,EAAE,+BAA+B;IA8D7C,SAAS,IAAI,OAAO,CAAC,YAAY,CAAC;IAIxC,SAAS,CAAC,WAAW,IAAI,eAAe;cAIxB,YAAY,IAAI,OAAO,CAAC,YAAY,CAAC;cA2ErC,cAAc,IAAI,OAAO,CAAC,YAAY,CAAC;CA0CxD"}
1
+ {"version":3,"file":"AuthorizationCodeProvider.d.ts","sourceRoot":"","sources":["../../src/providers/AuthorizationCodeProvider.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAEV,OAAO,EACP,YAAY,EACZ,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAIlC,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAKxD,MAAM,WAAW,+BAA+B;IAE9C,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IAGrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAG1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IAGtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IAGtB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;;;;GAKG;AACH,qBAAa,yBAA0B,SAAQ,iBAAiB;IAC9D,OAAO,CAAC,MAAM,CAAkC;gBAEpC,MAAM,EAAE,+BAA+B;IA8D7C,SAAS,IAAI,OAAO,CAAC,YAAY,CAAC;IAIxC,SAAS,CAAC,WAAW,IAAI,eAAe;cAIxB,YAAY,IAAI,OAAO,CAAC,YAAY,CAAC;cAoErC,cAAc,IAAI,OAAO,CAAC,YAAY,CAAC;CA0CxD"}