@mcp-abap-adt/auth-providers 1.2.0 → 2.0.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +373 -62
  3. package/bin/auth-authorization-code.ts +5 -3
  4. package/dist/__tests__/helpers/netHelpers.d.ts +16 -0
  5. package/dist/__tests__/helpers/netHelpers.d.ts.map +1 -1
  6. package/dist/__tests__/helpers/netHelpers.js +29 -0
  7. package/dist/auth/announce.d.ts +11 -0
  8. package/dist/auth/announce.d.ts.map +1 -0
  9. package/dist/auth/announce.js +19 -0
  10. package/dist/auth/browserAuth.d.ts +21 -14
  11. package/dist/auth/browserAuth.d.ts.map +1 -1
  12. package/dist/auth/browserAuth.js +25 -111
  13. package/dist/auth/callbackServer.d.ts +6 -0
  14. package/dist/auth/callbackServer.d.ts.map +1 -1
  15. package/dist/auth/callbackServer.js +31 -21
  16. package/dist/auth/oidcBrowserAuth.d.ts +2 -9
  17. package/dist/auth/oidcBrowserAuth.d.ts.map +1 -1
  18. package/dist/auth/oidcBrowserAuth.js +16 -125
  19. package/dist/auth/saml2Auth.d.ts +2 -9
  20. package/dist/auth/saml2Auth.d.ts.map +1 -1
  21. package/dist/auth/saml2Auth.js +10 -130
  22. package/dist/index.d.ts +6 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +21 -1
  25. package/dist/providers/AuthorizationCodeProvider.d.ts +7 -3
  26. package/dist/providers/AuthorizationCodeProvider.d.ts.map +1 -1
  27. package/dist/providers/AuthorizationCodeProvider.js +51 -40
  28. package/dist/providers/DeviceFlowProvider.d.ts +2 -1
  29. package/dist/providers/DeviceFlowProvider.d.ts.map +1 -1
  30. package/dist/providers/DeviceFlowProvider.js +13 -10
  31. package/dist/providers/OidcBrowserProvider.d.ts +4 -7
  32. package/dist/providers/OidcBrowserProvider.d.ts.map +1 -1
  33. package/dist/providers/OidcBrowserProvider.js +54 -57
  34. package/dist/providers/OidcDeviceFlowProvider.d.ts.map +1 -1
  35. package/dist/providers/OidcDeviceFlowProvider.js +9 -7
  36. package/dist/providers/Saml2BearerProvider.d.ts +2 -2
  37. package/dist/providers/Saml2BearerProvider.d.ts.map +1 -1
  38. package/dist/providers/Saml2BearerProvider.js +3 -0
  39. package/dist/providers/Saml2PureProvider.d.ts +2 -2
  40. package/dist/providers/Saml2PureProvider.d.ts.map +1 -1
  41. package/dist/providers/Saml2PureProvider.js +3 -0
  42. package/dist/providers/saml2Utils.d.ts +12 -12
  43. package/dist/providers/saml2Utils.d.ts.map +1 -1
  44. package/dist/providers/saml2Utils.js +46 -26
  45. package/dist/strategies/BrowserCallbackStrategy.d.ts +64 -0
  46. package/dist/strategies/BrowserCallbackStrategy.d.ts.map +1 -0
  47. package/dist/strategies/BrowserCallbackStrategy.js +210 -0
  48. package/dist/strategies/asOidcResult.d.ts +11 -0
  49. package/dist/strategies/asOidcResult.d.ts.map +1 -0
  50. package/dist/strategies/asOidcResult.js +30 -0
  51. package/dist/strategies/codeStrategies.d.ts +22 -0
  52. package/dist/strategies/codeStrategies.d.ts.map +1 -0
  53. package/dist/strategies/codeStrategies.js +39 -0
  54. package/dist/strategies/index.d.ts +8 -0
  55. package/dist/strategies/index.d.ts.map +1 -0
  56. package/dist/strategies/index.js +18 -0
  57. package/dist/strategies/manualStrategies.d.ts +20 -0
  58. package/dist/strategies/manualStrategies.d.ts.map +1 -0
  59. package/dist/strategies/manualStrategies.js +77 -0
  60. package/package.json +2 -2
  61. package/dist/auth/manualInput.d.ts +0 -5
  62. package/dist/auth/manualInput.d.ts.map +0 -1
  63. package/dist/auth/manualInput.js +0 -19
@@ -1,3 +1,19 @@
1
1
  export declare function getAvailablePort(): Promise<number>;
2
2
  export declare function canListenOnLocalhost(): Promise<boolean>;
3
+ /**
4
+ * Whether a login in this test could own `port` — and therefore whether
5
+ * asserting that it released the port means anything.
6
+ *
7
+ * A release assertion is a claim about our own cleanup. When an unrelated
8
+ * process already holds the port, the login fails at the probe, never binds
9
+ * anything, and cannot release anything: the port is still held afterwards,
10
+ * the code is behaving exactly as it should, and the assertion fails anyway.
11
+ * That is a test reporting on what else is running on the machine.
12
+ *
13
+ * So the gate is placed *before* the login rather than the assertion being
14
+ * widened until it always passes — a `toBe(true)` relaxed into "free or held"
15
+ * would also pass when our own cleanup leaked, which is the one thing these
16
+ * cases exist to catch.
17
+ */
18
+ export declare function canOwnPort(port: number, context: string): Promise<boolean>;
3
19
  //# sourceMappingURL=netHelpers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"netHelpers.d.ts","sourceRoot":"","sources":["../../../src/__tests__/helpers/netHelpers.ts"],"names":[],"mappings":"AAEA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,MAAM,CAAC,CAcxD;AAED,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC,CAQ7D"}
1
+ {"version":3,"file":"netHelpers.d.ts","sourceRoot":"","sources":["../../../src/__tests__/helpers/netHelpers.ts"],"names":[],"mappings":"AAEA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,MAAM,CAAC,CAcxD;AAED,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC,CAQ7D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,OAAO,CAAC,CAclB"}
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.getAvailablePort = getAvailablePort;
37
37
  exports.canListenOnLocalhost = canListenOnLocalhost;
38
+ exports.canOwnPort = canOwnPort;
38
39
  const net = __importStar(require("node:net"));
39
40
  async function getAvailablePort() {
40
41
  return new Promise((resolve, reject) => {
@@ -61,3 +62,31 @@ async function canListenOnLocalhost() {
61
62
  });
62
63
  });
63
64
  }
65
+ /**
66
+ * Whether a login in this test could own `port` — and therefore whether
67
+ * asserting that it released the port means anything.
68
+ *
69
+ * A release assertion is a claim about our own cleanup. When an unrelated
70
+ * process already holds the port, the login fails at the probe, never binds
71
+ * anything, and cannot release anything: the port is still held afterwards,
72
+ * the code is behaving exactly as it should, and the assertion fails anyway.
73
+ * That is a test reporting on what else is running on the machine.
74
+ *
75
+ * So the gate is placed *before* the login rather than the assertion being
76
+ * widened until it always passes — a `toBe(true)` relaxed into "free or held"
77
+ * would also pass when our own cleanup leaked, which is the one thing these
78
+ * cases exist to catch.
79
+ */
80
+ async function canOwnPort(port, context) {
81
+ const free = await new Promise((resolve) => {
82
+ const probe = net.createServer();
83
+ probe.once('error', () => resolve(false));
84
+ probe.listen(port, () => probe.close(() => resolve(true)));
85
+ });
86
+ if (!free) {
87
+ console.warn(`⚠️ Port ${port} is held by another process — skipping the port-release ` +
88
+ `assertions in "${context}". A login that never bound the socket cannot ` +
89
+ 'release it; the remaining assertions in this case still run.');
90
+ }
91
+ return free;
92
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Where an essential, user-facing prompt goes.
3
+ *
4
+ * Prompts are not log lines: a device code or an authorization URL the user
5
+ * cannot see makes the flow impassable, so they must survive the absence of a
6
+ * logger. They must equally never reach stdout, which carries protocol traffic
7
+ * under an MCP or LSP stdio transport.
8
+ */
9
+ import type { ILogger } from '@mcp-abap-adt/interfaces';
10
+ export declare function announcer(logger?: ILogger): (msg: string) => void;
11
+ //# sourceMappingURL=announce.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"announce.d.ts","sourceRoot":"","sources":["../../src/auth/announce.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAExD,wBAAgB,SAAS,CAAC,MAAM,CAAC,EAAE,OAAO,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAKjE"}
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ /**
3
+ * Where an essential, user-facing prompt goes.
4
+ *
5
+ * Prompts are not log lines: a device code or an authorization URL the user
6
+ * cannot see makes the flow impassable, so they must survive the absence of a
7
+ * logger. They must equally never reach stdout, which carries protocol traffic
8
+ * under an MCP or LSP stdio transport.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.announcer = announcer;
12
+ function announcer(logger) {
13
+ return (msg) => {
14
+ if (logger)
15
+ logger.info(msg);
16
+ else
17
+ process.stderr.write(`${msg}\n`);
18
+ };
19
+ }
@@ -2,9 +2,6 @@
2
2
  * Browser authentication - OAuth2 flow for obtaining tokens
3
3
  */
4
4
  import type { IAuthorizationConfig, ILogger } from '@mcp-abap-adt/interfaces';
5
- type BrowserAuthConfig = IAuthorizationConfig & {
6
- authorizationUrl?: string;
7
- };
8
5
  /**
9
6
  * Extract an OAuth2 authorization code from arbitrary pasted input.
10
7
  *
@@ -17,25 +14,35 @@ type BrowserAuthConfig = IAuthorizationConfig & {
17
14
  * @internal - Exported for testing and for manual-paste flows.
18
15
  */
19
16
  export declare function extractCode(input: string): string | null;
17
+ /**
18
+ * Build the OAuth2 authorization URL for a redirect URI that is already known.
19
+ *
20
+ * The URI is a parameter rather than a port because the port may have been
21
+ * chosen by the OS moments earlier — see `ICallbackServerOptions.port`.
22
+ */
23
+ export declare function getJwtAuthorizationUrl(authConfig: IAuthorizationConfig, redirectUri: string): string;
20
24
  /**
21
25
  * Exchange authorization code for tokens
22
26
  * @internal - Exported for testing
23
27
  */
24
- export declare function exchangeCodeForToken(authConfig: IAuthorizationConfig, code: string, port?: number, log?: ILogger | null): Promise<{
28
+ export declare function exchangeCodeForToken(authConfig: IAuthorizationConfig, code: string, redirectUri: string, log?: ILogger | null): Promise<{
25
29
  accessToken: string;
26
30
  refreshToken?: string;
27
31
  }>;
28
32
  /**
29
- * Interactive browser login for the UAA authorization-code flow.
33
+ * Open the authorization URL, or tell the user how to do it.
30
34
  *
31
- * The callback socket is owned by `withBrowserCallbackServer`: it is released
32
- * when the scope ends, whatever ends it, and before the code is exchanged — so
33
- * a slow UAA cannot hold the port, and a settled promise always means the port
34
- * is free.
35
+ * Never awaited on the critical path by the caller: a launcher that hangs must
36
+ * not delay the login timeout or the release of the port. A launcher that fails
37
+ * is reported through the scope's `fail`, which is just another way for the
38
+ * scope to end.
35
39
  */
36
- export declare function startBrowserAuth(authConfig: BrowserAuthConfig, browser?: string, logger?: ILogger, port?: number, timeoutMs?: number): Promise<{
37
- accessToken: string;
38
- refreshToken?: string;
39
- }>;
40
- export {};
40
+ export declare function launchBrowser(authorizationUrl: string, browser: string, callbackUri: string, announce: (msg: string) => void, log: ILogger | null,
41
+ /**
42
+ * Extra guidance for 'none'/'headless', supplied only by a flow whose
43
+ * transport really offers another way in. The UAA callback server has a paste
44
+ * form on `/`; the OIDC and SAML ones do not, and promising one there sends
45
+ * the user to a 404.
46
+ */
47
+ remoteHint?: string): Promise<void>;
41
48
  //# sourceMappingURL=browserAuth.d.ts.map
@@ -1 +1 @@
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;AA8JD;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CACpC,UAAU,EAAE,iBAAiB,EAC7B,OAAO,GAAE,MAAiB,EAC1B,MAAM,CAAC,EAAE,OAAO,EAChB,IAAI,GAAE,MAAa,EACnB,SAAS,GAAE,MAAkB,GAC5B,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA2FzD"}
1
+ {"version":3,"file":"browserAuth.d.ts","sourceRoot":"","sources":["../../src/auth/browserAuth.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,KAAK,EAAE,oBAAoB,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAa9E;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiBxD;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,oBAAoB,EAChC,WAAW,EAAE,MAAM,GAClB,MAAM,CASR;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,UAAU,EAAE,oBAAoB,EAChC,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,GAAG,CAAC,EAAE,OAAO,GAAG,IAAI,GACnB,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA+CzD;AAeD;;;;;;;GAOG;AACH,wBAAsB,aAAa,CACjC,gBAAgB,EAAE,MAAM,EACxB,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,EAC/B,GAAG,EAAE,OAAO,GAAG,IAAI;AACnB;;;;;GAKG;AACH,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,IAAI,CAAC,CAiGf"}
@@ -40,14 +40,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
40
40
  };
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
42
  exports.extractCode = extractCode;
43
+ exports.getJwtAuthorizationUrl = getJwtAuthorizationUrl;
43
44
  exports.exchangeCodeForToken = exchangeCodeForToken;
44
- exports.startBrowserAuth = startBrowserAuth;
45
+ exports.launchBrowser = launchBrowser;
45
46
  const child_process = __importStar(require("node:child_process"));
46
- const http = __importStar(require("node:http"));
47
- const net = __importStar(require("node:net"));
48
- const readline = __importStar(require("node:readline"));
49
47
  const axios_1 = __importDefault(require("axios"));
50
- const callbackServer_1 = require("./callbackServer");
51
48
  const BROWSER_MAP = {
52
49
  chrome: 'chrome',
53
50
  edge: 'msedge',
@@ -89,12 +86,14 @@ function extractCode(input) {
89
86
  return trimmed;
90
87
  }
91
88
  /**
92
- * Get OAuth2 authorization URL
89
+ * Build the OAuth2 authorization URL for a redirect URI that is already known.
90
+ *
91
+ * The URI is a parameter rather than a port because the port may have been
92
+ * chosen by the OS moments earlier — see `ICallbackServerOptions.port`.
93
93
  */
94
- function getJwtAuthorizationUrl(authConfig, port = 3001) {
94
+ function getJwtAuthorizationUrl(authConfig, redirectUri) {
95
95
  const oauthUrl = authConfig.uaaUrl;
96
96
  const clientid = authConfig.uaaClientId;
97
- const redirectUri = `http://localhost:${port}/callback`;
98
97
  if (!oauthUrl || !clientid) {
99
98
  throw new Error('Authorization config missing UAA URL or client ID');
100
99
  }
@@ -104,10 +103,9 @@ function getJwtAuthorizationUrl(authConfig, port = 3001) {
104
103
  * Exchange authorization code for tokens
105
104
  * @internal - Exported for testing
106
105
  */
107
- async function exchangeCodeForToken(authConfig, code, port = 3001, log) {
106
+ async function exchangeCodeForToken(authConfig, code, redirectUri, log) {
108
107
  const { uaaUrl: url, uaaClientId: clientid, uaaClientSecret: clientsecret, } = authConfig;
109
108
  const tokenUrl = `${url}/oauth/token`;
110
- const redirectUri = `http://localhost:${port}/callback`;
111
109
  const params = new URLSearchParams();
112
110
  params.append('grant_type', 'authorization_code');
113
111
  params.append('code', code);
@@ -148,44 +146,29 @@ function _isDebugEnabled() {
148
146
  process.env.DEBUG?.includes('browser-auth') === true);
149
147
  }
150
148
  /**
151
- * Check if a port is available
149
+ * Open the authorization URL, or tell the user how to do it.
150
+ *
151
+ * Never awaited on the critical path by the caller: a launcher that hangs must
152
+ * not delay the login timeout or the release of the port. A launcher that fails
153
+ * is reported through the scope's `fail`, which is just another way for the
154
+ * scope to end.
152
155
  */
153
- function isPortAvailable(port) {
154
- return new Promise((resolve) => {
155
- const server = net.createServer();
156
- server.listen(port, () => {
157
- server.once('close', () => resolve(true));
158
- server.close();
159
- });
160
- server.on('error', () => resolve(false));
161
- });
162
- }
156
+ async function launchBrowser(authorizationUrl, browser, callbackUri, announce, log,
163
157
  /**
164
- * Start browser authentication flow
165
- * @param authConfig Authorization configuration with UAA credentials
166
- * @param browser Browser name (chrome, edge, firefox, system, none)
167
- * @param logger Optional logger instance. If not provided, uses default logger.
168
- * @param port Port for OAuth callback server (default: 3001)
169
- * @returns Promise that resolves to tokens
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) {
158
+ * Extra guidance for 'none'/'headless', supplied only by a flow whose
159
+ * transport really offers another way in. The UAA callback server has a paste
160
+ * form on `/`; the OIDC and SAML ones do not, and promising one there sends
161
+ * the user to a 404.
162
+ */
163
+ remoteHint) {
180
164
  const browserApp = BROWSER_MAP[browser];
181
165
  // 'none' / 'headless': show the URL and wait. For SSH and remote sessions.
182
166
  if (browser === 'none' || browser === 'headless') {
183
167
  announce('🔗 Open this URL in your browser to authenticate:');
184
168
  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.`);
169
+ announce(` Waiting for callback on ${callbackUri} ...`);
170
+ if (remoteHint)
171
+ announce(remoteHint);
189
172
  return;
190
173
  }
191
174
  if (browser === 'auto') {
@@ -200,7 +183,7 @@ async function launchBrowser(authorizationUrl, browser, port, announce, log) {
200
183
  log?.warn(`⚠️ Could not open browser automatically: ${message}`);
201
184
  announce('🔗 Please open this URL in your browser to authenticate:');
202
185
  announce(` ${authorizationUrl}`);
203
- announce(` Waiting for callback on http://localhost:${port}/callback ...`);
186
+ announce(` Waiting for callback on ${callbackUri} ...`);
204
187
  }
205
188
  return;
206
189
  }
@@ -269,72 +252,3 @@ async function launchBrowser(authorizationUrl, browser, port, announce, log) {
269
252
  else
270
253
  await open(authorizationUrl);
271
254
  }
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.
279
- */
280
- async function startBrowserAuth(authConfig, browser = 'system', logger, port = 3001, timeoutMs = 30 * 1000) {
281
- const log = logger || null;
282
- // Essential, user-facing prompts (the auth URL, paste instructions) must be
283
- // visible even when no logger is supplied. Fall back to stderr — never stdout,
284
- // so stdio-based RPC transports (MCP/LSP) are not corrupted.
285
- const announce = (msg) => {
286
- if (log)
287
- log.info(msg);
288
- else
289
- process.stderr.write(`${msg}\n`);
290
- };
291
- // Pre-check kept for its message: AuthBroker matches /already in use/i to
292
- // distinguish a busy port from other failures.
293
- const portAvailable = await isPortAvailable(port);
294
- if (!portAvailable) {
295
- throw new Error(`Port ${port} is already in use. Please specify a different port or free the port.`);
296
- }
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);
301
- log?.info(`[browserAuth] Authorization URL: ${authorizationUrl}`);
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');
322
- return;
323
- }
324
- const req = http.get({
325
- host: '127.0.0.1',
326
- port: server.port,
327
- path: `/submit?input=${encodeURIComponent(pasted)}`,
328
- agent: false,
329
- });
330
- req.on('error', () => undefined);
331
- });
332
- }
333
- return await waiting;
334
- }).finally(() => {
335
- stdinReader?.close();
336
- stdinReader = null;
337
- });
338
- log?.info('[browserAuth] Exchanging code for token...');
339
- return await exchangeCodeForToken(authConfig, code, port, log);
340
- }
@@ -20,6 +20,12 @@ export interface Settle<TResult> {
20
20
  ok(value: TResult, res?: express.Response): void;
21
21
  /** The callback reported a failure. Ends the scope. */
22
22
  err(error: Error, res?: express.Response): void;
23
+ /**
24
+ * This was not our redirect — a reloaded tab, a prefetch, a port scanner.
25
+ * Answered and counted; the login keeps waiting, bounded as ever by the
26
+ * timeout. Ends nothing.
27
+ */
28
+ ignore(reason: string, res?: express.Response): void;
23
29
  }
24
30
  export type RouteSetup<TResult> = (app: express.Express, settle: Settle<TResult>) => void;
25
31
  /**
@@ -1 +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"}
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;IAChD;;;;OAIG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;CACtD;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,CAqMlB;AAyCD;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,EAAE,qBAAqB,CAAC,MAAM,CA4DjE,CAAC"}
@@ -57,8 +57,8 @@ const MAX_TIMEOUT_MS = 2_147_483_647;
57
57
  const SHUTDOWN_GRACE_MS = 500;
58
58
  function validate(options) {
59
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.`);
60
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
61
+ throw new Error(`Invalid callback server port: ${String(port)}. Must be an integer in 0..65535.`);
62
62
  }
63
63
  if (!Number.isFinite(timeoutMs) ||
64
64
  timeoutMs <= 0 ||
@@ -105,6 +105,7 @@ async function runCallbackScope(options, routes, use) {
105
105
  });
106
106
  let timer = null;
107
107
  let alive = false;
108
+ let ignored = 0;
108
109
  const settleResult = (outcome) => {
109
110
  if (resultSettled)
110
111
  return;
@@ -202,25 +203,13 @@ async function runCallbackScope(options, routes, use) {
202
203
  endScope({ error });
203
204
  });
204
205
  },
206
+ ignore(reason) {
207
+ ignored += 1;
208
+ options.logger?.warn('[callbackServer] ignored an incomplete callback request', { reason, ignored });
209
+ },
205
210
  };
206
211
  routes(app, settle);
207
212
  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
213
  server.once('error', (error) => {
225
214
  endScope({ error });
226
215
  });
@@ -232,10 +221,31 @@ async function runCallbackScope(options, routes, use) {
232
221
  }
233
222
  alive = true;
234
223
  timer = setTimeout(() => {
224
+ const tally = ignored > 0
225
+ ? ` ${ignored} incomplete request(s) reached /callback and were ignored.`
226
+ : '';
235
227
  endScope({
236
- error: new Error(`Authentication timeout after ${options.timeoutMs / 1000} seconds. Please try again.`),
228
+ error: new Error(`Authentication timeout after ${options.timeoutMs / 1000} seconds. Please try again.${tally}`),
237
229
  });
238
230
  }, options.timeoutMs);
231
+ // The requested port may be 0, in which case only the OS knows the answer.
232
+ const bound = server.address().port;
233
+ const handle = {
234
+ port: bound,
235
+ redirectUri: `http://localhost:${bound}/callback`,
236
+ waitForResult: () => alive
237
+ ? resultPromise
238
+ : Promise.reject(new Error('Callback server scope has ended')),
239
+ // Silent no-op once the scope has ended: this is called fire-and-forget
240
+ // from a browser launcher's .catch(), and a late rejection must not become
241
+ // a fresh unhandled rejection.
242
+ fail: (error) => {
243
+ if (!alive)
244
+ return;
245
+ settleResult({ error });
246
+ endScope({ error });
247
+ },
248
+ };
239
249
  void use(handle).then((value) => endScope({ value }), (error) => endScope({ error }));
240
250
  });
241
251
  return await scopePromise;
@@ -296,8 +306,8 @@ const withBrowserCallbackServer = (options, use) => runCallbackScope(options, (a
296
306
  }
297
307
  const { code } = req.query;
298
308
  if (!code || typeof code !== 'string') {
299
- res.status(400).send('Error: Authorization code missing');
300
- settle.err(new Error('Authorization code missing'), res);
309
+ res.status(400).send('Error: not an authorization callback');
310
+ settle.ignore('no code and no error in query', res);
301
311
  return;
302
312
  }
303
313
  res.send(successHtml);
@@ -1,17 +1,10 @@
1
1
  /**
2
2
  * OIDC browser authorization code flow (capture code)
3
3
  */
4
- import type { ILogger } from '@mcp-abap-adt/interfaces';
4
+ import type { CallbackServerFactory } from '@mcp-abap-adt/interfaces';
5
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
+ export declare const withOidcCallbackServer: CallbackServerFactory<OidcCallbackResult>;
17
10
  //# sourceMappingURL=oidcBrowserAuth.d.ts.map
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"oidcBrowserAuth.d.ts","sourceRoot":"","sources":["../../src/auth/oidcBrowserAuth.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EACV,qBAAqB,EAGtB,MAAM,0BAA0B,CAAC;AAGlC,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,eAAO,MAAM,sBAAsB,EAAE,qBAAqB,CACxD,kBAAkB,CA6CjB,CAAC"}