@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
package/README.md CHANGED
@@ -20,6 +20,15 @@ This package implements the `ITokenProvider` interface from `@mcp-abap-adt/inter
20
20
 
21
21
  Providers are configured via constructor; `getTokens()` takes no parameters and handles refresh/login internally.
22
22
 
23
+ Since 2.0.0 an interactive login is conducted by an **authorization strategy**
24
+ (`IAuthorizationStrategy` from `@mcp-abap-adt/interfaces`) passed as
25
+ `authorization`. The provider owns what it can compute — the authorization URL
26
+ and the token exchange; everything between them (reaching the URL, receiving
27
+ what comes back, the port, the timeout) belongs to the strategy, which a
28
+ consumer may replace wholesale. See
29
+ [Choosing an authorization strategy](#choosing-an-authorization-strategy) and,
30
+ if you are on 1.x, [Migrating from 1.x to 2.0](#migrating-from-1x-to-20).
31
+
23
32
  ## Responsibilities and Design Principles
24
33
 
25
34
  ### Core Development Principle
@@ -78,7 +87,11 @@ This package interacts with external packages **ONLY through interfaces**:
78
87
 
79
88
  ```typescript
80
89
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
81
- import { AuthorizationCodeProvider, ClientCredentialsProvider } from '@mcp-abap-adt/auth-providers';
90
+ import {
91
+ AuthorizationCodeProvider,
92
+ ClientCredentialsProvider,
93
+ browserCallbackStrategy,
94
+ } from '@mcp-abap-adt/auth-providers';
82
95
 
83
96
  // User token via authorization_code (browser flow)
84
97
  const authCodeBroker = new AuthBroker({
@@ -86,7 +99,7 @@ const authCodeBroker = new AuthBroker({
86
99
  uaaUrl: 'https://...',
87
100
  clientId: '...',
88
101
  clientSecret: '...',
89
- browser: 'system',
102
+ authorization: browserCallbackStrategy({ browser: 'system' }),
90
103
  }),
91
104
  });
92
105
 
@@ -100,35 +113,141 @@ const clientCredsBroker = new AuthBroker({
100
113
  }, 'none');
101
114
  ```
102
115
 
103
- ### Browser modes (`AuthorizationCodeProvider`)
116
+ ### Choosing an authorization strategy
117
+
118
+ `authorization` decides how an interactive login is conducted. Omit it and the
119
+ provider builds the callback strategy for its own flow, on the default port —
120
+ which is convenient, and is also the only case where the default port applies
121
+ without you having chosen it. Every shipped strategy is a plain function
122
+ returning `IAuthorizationStrategy`, so a consumer can pass its own instead.
123
+
124
+ | Strategy | For | What it does |
125
+ |---|---|---|
126
+ | `browserCallbackStrategy(opts)` | `AuthorizationCodeProvider` | Binds a local callback server, opens the URL, waits for `?code=` |
127
+ | `oidcCallbackStrategy(opts)` | `OidcBrowserProvider` | The same, yielding `{ code, state }` |
128
+ | `samlCallbackStrategy(opts)` | `Saml2BearerProvider`, `Saml2PureProvider` | The same, receiving a posted `SAMLResponse` |
129
+ | `manualPasteStrategy({ redirectUri, read })` | code flows | Shows the URL, reads the pasted code (stdin by default) |
130
+ | `manualSamlResponseStrategy({ redirectUri, read })` | SAML flows | Shows the URL, reads the pasted `SAMLResponse` |
131
+ | `externalCodeStrategy({ redirectUri, provide })` | either | Hands the assembled URL to your function, takes back the payload |
132
+ | `staticCodeStrategy({ redirectUri, payload })` | either | You already hold the payload; the URL is never built |
133
+ | your own | any | Implement `IAuthorizationStrategy<TResult>` and pass it |
134
+
135
+ Options common to the three callback strategies:
136
+
137
+ | Option | Default | Meaning |
138
+ |---|---|---|
139
+ | `port` | `61001` (`DEFAULT_CALLBACK_PORT`) | Port to bind. `0` binds an ephemeral one — usable only where the identity provider accepts a loopback redirect on any port, never where a fixed redirect URI is registered |
140
+ | `timeoutMs` | `30000` (`DEFAULT_LOGIN_TIMEOUT_MS`) | How long the login may wait for its callback |
141
+ | `browser` | `'none'` | `'none'` / `'headless'` print the URL; `'system'`, `'auto'`, `'chrome'`, `'edge'`, `'firefox'` open it |
142
+ | `callbackServer` | the one this package ships | Your own `CallbackServerFactory`, to reuse a server you already run |
143
+ | `openUrl` | the built-in launcher | Receives `(url, browser, redirectUri)` |
144
+ | `remoteHint` | the paste hint, only for the shipped UAA transport | Extra guidance printed in `'none'` / `'headless'` mode |
145
+ | `signal` | — | `AbortSignal` cancelling the login |
146
+
147
+ Note the `browser` default: **`'none'`, so nothing is opened unless you ask for
148
+ it.** The URL is always shown, even with no logger — it falls back to `stderr`,
149
+ never stdout, so an MCP/LSP stdio transport is not corrupted. (1.x behaved the
150
+ same way; the 1.x README claiming `system` was the default was wrong.)
151
+
152
+ The three `CallbackServerFactory` implementations are exported too —
153
+ `withBrowserCallbackServer`, `withOidcCallbackServer`, `withSamlCallbackServer`
154
+ — so a consumer can keep the transport and replace everything around it, or the
155
+ reverse.
156
+
157
+ For the three shipped flows, passing `callbackServer` to a ready constructor is
158
+ the way to substitute a transport. The `BrowserCallbackStrategy` class behind
159
+ them is exported as well, for the case the constructors cannot express: a
160
+ receiver whose payload is none of the three shapes those flows deliver. Its
161
+ options are the same, except `callbackServer` is required — there is no default
162
+ transport to fall back on when the payload type is your own.
163
+
164
+ ```typescript
165
+ import { BrowserCallbackStrategy } from '@mcp-abap-adt/auth-providers';
166
+
167
+ const strategy = new BrowserCallbackStrategy<MyPayload>({
168
+ callbackServer: withMyOwnCallbackServer, // CallbackServerFactory<MyPayload>
169
+ port: 61001,
170
+ timeoutMs: 30000,
171
+ });
172
+ ```
104
173
 
105
- The `browser` option controls how the authorization URL is opened:
174
+ #### Bringing your own
106
175
 
107
- | Mode | Behaviour |
108
- |------|-----------|
109
- | `system` (default) | Open the OS default browser |
110
- | `chrome` / `edge` / `firefox` | Open a specific browser |
111
- | `auto` | Try to open a browser; on failure, print the URL and wait |
112
- | `none` / `headless` | Do **not** open a browser — print the URL and wait for the code (SSH / remote / containers) |
176
+ ```typescript
177
+ import type { IAuthorizationStrategy } from '@mcp-abap-adt/interfaces';
178
+
179
+ const fromOurPortal: IAuthorizationStrategy<string> = {
180
+ async authorize(request) {
181
+ const redirectUri = 'https://portal.internal/oauth/callback';
182
+ const url = await request.buildAuthorizationUrl(redirectUri);
183
+ // The redirect URI you return is the one sent to the token endpoint.
184
+ return { payload: await ourPortal.login(url), redirectUri };
185
+ },
186
+ async dispose() { await ourPortal.close(); },
187
+ };
188
+ ```
113
189
 
114
- In `none`/`headless` mode the authorization URL is always shown, **even when no
115
- logger is supplied** (it falls back to `stderr`, never stdout, so stdio-based
116
- RPC transports are not corrupted).
190
+ `dispose` is optional, and whoever constructs a strategy disposes of it: a
191
+ strategy you pass in is yours to dispose, one the provider defaulted to is
192
+ disposed by the provider.
117
193
 
118
- #### Manual paste (none / headless)
194
+ #### Manual paste over a callback server
119
195
 
120
- Login can complete through any of three channels — whichever finishes first wins:
196
+ With `browserCallbackStrategy` (the UAA transport), login can complete through
197
+ either of **two** channels — whichever finishes first wins:
121
198
 
122
- 1. **Automatic callback** — `GET /callback?code=...` on `http://localhost:<redirectPort>`.
199
+ 1. **Automatic callback** — `GET /callback?code=...` on the bound redirect URI.
123
200
  Works when the browser is on the same machine as the process.
124
- 2. **Paste form** — open `http://<host>:<redirectPort>/` and paste the code (or
125
- the whole redirected URL). Works when the browser is on a *different* machine,
126
- since the callback server listens on all interfaces.
127
- 3. **Terminal paste** — paste the code on stdin and press Enter. Only active when
128
- `process.stdin.isTTY` (stdin is never consumed under a stdio RPC transport).
201
+ 2. **Paste form** — open `http://<this-host>:<port>/` and paste the code (or the
202
+ whole redirected URL). Works when the browser is on a *different* machine,
203
+ since the callback server listens on all interfaces. In `'none'` /
204
+ `'headless'` mode the strategy prints this address for you — with the real
205
+ port and the host left for you to fill in, because the process cannot know
206
+ which of its addresses you can reach.
207
+
208
+ **The terminal-paste channel is gone.** In 1.x a third channel read the code
209
+ from stdin when `process.stdin.isTTY`; `browserCallbackStrategy` has no such
210
+ reader, and this is deliberate rather than an oversight — under an MCP or LSP
211
+ stdio transport stdin carries the protocol, and an authorization library has no
212
+ business consuming it. Reading a pasted code is now a strategy of its own:
213
+
214
+ ```typescript
215
+ import {
216
+ AuthorizationCodeProvider,
217
+ manualPasteStrategy,
218
+ } from '@mcp-abap-adt/auth-providers';
219
+
220
+ const provider = new AuthorizationCodeProvider({
221
+ uaaUrl, clientId, clientSecret,
222
+ // Binds no socket at all: prints the URL, then reads one line.
223
+ // Defaults to stdin when it is a TTY — pass `read` to source it anywhere else.
224
+ authorization: manualPasteStrategy({
225
+ redirectUri: 'http://localhost:61001/callback',
226
+ }),
227
+ });
228
+ ```
129
229
 
130
- The exported `extractCode(input)` helper accepts a bare code, `code=...`, or a
131
- full redirected URL.
230
+ `manualPasteStrategy` reads from stdin only when `process.stdin.isTTY`, and
231
+ throws a clear error otherwise rather than consuming a protocol stream. Supply
232
+ `read` to take the value from somewhere else entirely — a TUI prompt, an HTTP
233
+ request, a file:
234
+
235
+ ```typescript
236
+ authorization: manualPasteStrategy({
237
+ redirectUri: 'http://localhost:61001/callback',
238
+ read: async (prompt) => askInOurUi(prompt),
239
+ })
240
+ ```
241
+
242
+ The `redirectUri` you give it must be the one the identity provider will
243
+ redirect to; it is also the one sent to the token endpoint. It defaults to
244
+ `http://localhost:61001/callback`.
245
+
246
+ Both the paste form and `manualPasteStrategy` accept a bare code, `code=...`,
247
+ or a full redirected URL — whichever you paste, the code is extracted from it.
248
+
249
+ > The `extractCode(input)` helper behind that leniency is internal; it is not
250
+ > part of the package's exports, contrary to what the 1.1.0–1.2.0 README said.
132
251
 
133
252
  ### SSO Providers
134
253
 
@@ -146,7 +265,10 @@ Factory example:
146
265
 
147
266
  ```typescript
148
267
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
149
- import { SsoProviderFactory } from '@mcp-abap-adt/auth-providers';
268
+ import {
269
+ SsoProviderFactory,
270
+ oidcCallbackStrategy,
271
+ } from '@mcp-abap-adt/auth-providers';
150
272
 
151
273
  const tokenProvider = SsoProviderFactory.create({
152
274
  protocol: 'oidc',
@@ -156,59 +278,101 @@ const tokenProvider = SsoProviderFactory.create({
156
278
  clientId: '...',
157
279
  clientSecret: '...',
158
280
  scopes: ['openid', 'profile', 'email'],
159
- browser: 'system',
281
+ authorization: oidcCallbackStrategy({ browser: 'system' }),
160
282
  },
161
283
  });
162
284
 
163
285
  const broker = new AuthBroker({ tokenProvider }, 'none');
164
286
  ```
165
287
 
166
- OIDC browser example (manual code + explicit endpoints):
288
+ OIDC browser example (a code you already hold + explicit endpoints):
167
289
 
168
290
  ```typescript
169
- import { OidcBrowserProvider } from '@mcp-abap-adt/auth-providers';
291
+ import {
292
+ OidcBrowserProvider,
293
+ asOidcResult,
294
+ staticCodeStrategy,
295
+ } from '@mcp-abap-adt/auth-providers';
296
+
297
+ const redirectUri = 'urn:ietf:wg:oauth:2.0:oob';
170
298
 
171
299
  const provider = new OidcBrowserProvider({
172
300
  clientId: '...',
173
301
  tokenEndpoint: 'https://issuer/oauth/token',
174
302
  authorizationEndpoint: 'https://issuer/oauth/authorize',
175
- authorizationCode: '<paste-code-here>',
176
- redirectUri: 'urn:ietf:wg:oauth:2.0:oob',
303
+ authorization: asOidcResult(
304
+ staticCodeStrategy({ redirectUri, payload: '<paste-code-here>' }),
305
+ ),
177
306
  });
178
307
  ```
179
308
 
180
- SAML bearer example (manual flow):
309
+ `asOidcResult` is not optional here. `OidcBrowserProvider` takes
310
+ `IAuthorizationStrategy<OidcCallbackResult>`, and the code-producing strategies
311
+ (`staticCodeStrategy`, `externalCodeStrategy`, `manualPasteStrategy`) yield a
312
+ `string`; passing one directly does not type-check. The adapter wraps the code
313
+ as `{ code }` — a value that never travelled through a redirect carries no
314
+ `state` to check — and delegates `dispose`, so wrapping costs nothing in
315
+ lifecycle terms.
316
+
317
+ The redirect URI is no longer a provider field: it belongs to the strategy,
318
+ because with an ephemeral port nothing knows it until the socket is bound. The
319
+ one the strategy reports is the one sent to the token endpoint.
320
+
321
+ SAML bearer example (manual paste):
181
322
 
182
323
  ```typescript
183
324
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
184
- import { Saml2BearerProvider } from '@mcp-abap-adt/auth-providers';
325
+ import {
326
+ Saml2BearerProvider,
327
+ manualSamlResponseStrategy,
328
+ } from '@mcp-abap-adt/auth-providers';
329
+
330
+ const acsUrl = 'https://sp.example.com/saml/acs';
185
331
 
186
332
  const provider = new Saml2BearerProvider({
187
- assertionFlow: 'manual',
188
333
  idpSsoUrl: 'https://idp.example.com/sso',
189
334
  spEntityId: 'my-sp-entity',
335
+ acsUrl,
190
336
  uaaUrl: 'https://uaa.example.com',
191
337
  clientId: '...',
192
338
  clientSecret: '...',
339
+ // `redirectUri` must equal `acsUrl`, or the provider refuses the mismatch.
340
+ authorization: manualSamlResponseStrategy({ redirectUri: acsUrl, read: promptUser }),
193
341
  });
194
342
 
195
343
  const broker = new AuthBroker({ tokenProvider: provider }, 'none');
196
344
  ```
197
345
 
198
- SAML bearer example (headless, assertion provider):
346
+ **Read that `redirectUri` twice.** A SAML strategy defaults its redirect URI to
347
+ `http://localhost:61001/callback`, and the provider requires the assertion
348
+ consumer service the IdP posts to be exactly the one the strategy names. If you
349
+ declare a real `acsUrl` and leave `redirectUri` off, the login fails with
350
+ *"SAML acsUrl is … but the authorization strategy is listening on …"* before
351
+ anything is opened. Declare neither and the default is used for both, which is
352
+ consistent — and only reachable when the IdP will post to your localhost.
353
+
354
+ SAML bearer example (headless, assertion fetched elsewhere):
199
355
 
200
356
  ```typescript
201
357
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
202
- import { Saml2BearerProvider } from '@mcp-abap-adt/auth-providers';
358
+ import {
359
+ Saml2BearerProvider,
360
+ externalCodeStrategy,
361
+ } from '@mcp-abap-adt/auth-providers';
362
+
363
+ const acsUrl = 'https://sp.example.com/saml/acs';
203
364
 
204
365
  const provider = new Saml2BearerProvider({
205
- assertionFlow: 'assertion',
206
- assertionProvider: async () => {
207
- return getSamlResponseFromSsoProxy();
208
- },
366
+ idpSsoUrl: 'https://idp.example.com/sso',
367
+ spEntityId: 'my-sp-entity',
368
+ acsUrl,
209
369
  uaaUrl: 'https://uaa.example.com',
210
370
  clientId: '...',
211
371
  clientSecret: '...',
372
+ authorization: externalCodeStrategy({
373
+ redirectUri: acsUrl,
374
+ provide: async (_authorizationUrl) => getSamlResponseFromSsoProxy(),
375
+ }),
212
376
  });
213
377
 
214
378
  const broker = new AuthBroker({ tokenProvider: provider }, 'none');
@@ -218,12 +382,18 @@ Pure SAML example (cookie-based):
218
382
 
219
383
  ```typescript
220
384
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
221
- import { Saml2PureProvider } from '@mcp-abap-adt/auth-providers';
385
+ import {
386
+ Saml2PureProvider,
387
+ manualSamlResponseStrategy,
388
+ } from '@mcp-abap-adt/auth-providers';
389
+
390
+ const acsUrl = 'https://sp.example.com/saml/acs';
222
391
 
223
392
  const provider = new Saml2PureProvider({
224
- assertionFlow: 'manual',
225
393
  idpSsoUrl: 'https://idp.example.com/sso',
226
394
  spEntityId: 'my-sp-entity',
395
+ acsUrl,
396
+ authorization: manualSamlResponseStrategy({ redirectUri: acsUrl, read: promptUser }),
227
397
  // Convert SAMLResponse to session cookies for SAP (implementation-specific)
228
398
  cookieProvider: async (samlResponse) => {
229
399
  return exchangeSamlForCookies(samlResponse);
@@ -233,6 +403,19 @@ const provider = new Saml2PureProvider({
233
403
  const broker = new AuthBroker({ tokenProvider: provider }, 'none');
234
404
  ```
235
405
 
406
+ Both SAML providers now reject at construction when `authorizationUrl` is set
407
+ without `acsUrl`:
408
+
409
+ ```
410
+ acsUrl is required when authorizationUrl is set: the ACS inside a pre-built
411
+ SAML request cannot be read, so it must be declared.
412
+ ```
413
+
414
+ The ACS is buried in a deflated `SAMLRequest` this package did not build and
415
+ cannot read, so it cannot be verified against whatever the strategy binds. 1.x
416
+ accepted the combination and defaulted the ACS to
417
+ `http://localhost:3001/callback` — usually not where the IdP posted.
418
+
236
419
  ### With Stores
237
420
 
238
421
  **Important**: BTP and ABAP are different entities:
@@ -241,7 +424,11 @@ const broker = new AuthBroker({ tokenProvider: provider }, 'none');
241
424
 
242
425
  ```typescript
243
426
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
244
- import { AuthorizationCodeProvider, ClientCredentialsProvider } from '@mcp-abap-adt/auth-providers';
427
+ import {
428
+ AuthorizationCodeProvider,
429
+ ClientCredentialsProvider,
430
+ browserCallbackStrategy,
431
+ } from '@mcp-abap-adt/auth-providers';
245
432
  import {
246
433
  XsuaaServiceKeyStore,
247
434
  XsuaaSessionStore,
@@ -276,7 +463,7 @@ const btpBroker = new AuthBroker({
276
463
  uaaUrl: 'https://...',
277
464
  clientId: '...',
278
465
  clientSecret: '...',
279
- browser: 'system',
466
+ authorization: browserCallbackStrategy({ browser: 'system' }),
280
467
  }),
281
468
  });
282
469
 
@@ -284,7 +471,7 @@ const btpBroker = new AuthBroker({
284
471
  const abapServiceKeyStore = new AbapServiceKeyStore('/path/to/service-keys');
285
472
  const abapSessionStore = new AbapSessionStore('/path/to/sessions');
286
473
 
287
- // Use custom port if running alongside other services (e.g., proxy on port 3001)
474
+ // Use a custom port if 61001 is taken, or if the IdP has a different one registered
288
475
  const abapBroker = new AuthBroker({
289
476
  serviceKeyStore: abapServiceKeyStore,
290
477
  sessionStore: abapSessionStore,
@@ -292,9 +479,8 @@ const abapBroker = new AuthBroker({
292
479
  uaaUrl: 'https://...',
293
480
  clientId: '...',
294
481
  clientSecret: '...',
295
- browser: 'system',
296
- redirectPort: 4001,
297
- }), // Custom port to avoid conflicts
482
+ authorization: browserCallbackStrategy({ browser: 'system', port: 4001 }),
483
+ }),
298
484
  });
299
485
  ```
300
486
 
@@ -305,13 +491,16 @@ const abapBroker = new AuthBroker({
305
491
  Uses browser-based OAuth2 flow or refresh token:
306
492
 
307
493
  ```typescript
308
- import { AuthorizationCodeProvider } from '@mcp-abap-adt/auth-providers';
494
+ import {
495
+ AuthorizationCodeProvider,
496
+ browserCallbackStrategy,
497
+ } from '@mcp-abap-adt/auth-providers';
309
498
 
310
499
  const provider = new AuthorizationCodeProvider({
311
500
  uaaUrl: 'https://...authentication...hana.ondemand.com',
312
501
  clientId: '...',
313
502
  clientSecret: '...',
314
- browser: 'system',
503
+ authorization: browserCallbackStrategy({ browser: 'system' }),
315
504
  });
316
505
 
317
506
  // If refreshToken is provided here, uses refresh flow (no browser)
@@ -341,34 +530,58 @@ const result = await provider.getTokens();
341
530
  // result.refreshToken is undefined (client_credentials doesn't provide refresh tokens)
342
531
  ```
343
532
 
344
- **Note**: The `redirectPort` parameter (default: 3001) configures the OAuth callback server port. If the requested port is already in use, an error is thrown; specify a different port or free it before starting authentication.
533
+ #### DeviceFlowProvider
534
+
535
+ `DeviceFlowProviderConfig` now accepts `logger?: ILogger`. The verification URI
536
+ and the user code are a prompt the user must see, not a log line: they go to the
537
+ logger when one is supplied and to **stderr** otherwise. They no longer go to
538
+ stdout — capturing stdout to read the device code will read nothing, and the
539
+ change exists because stdout carries protocol traffic under an MCP or LSP stdio
540
+ transport. `OidcDeviceFlowProvider` behaves the same way.
541
+
542
+ #### Callback port and lifetime
543
+
544
+ **Note**: the callback port is set on the strategy (`browserCallbackStrategy({ port })`
545
+ and its OIDC/SAML siblings), not on the provider — the 1.x `redirectPort` field
546
+ is gone. The default is **61001**, was 3001. If the requested port is already in
547
+ use, an error is thrown; specify a different port or free it before starting
548
+ authentication. `port: 0` binds an ephemeral port, which works only where the
549
+ identity provider accepts a loopback redirect on any port.
345
550
 
346
551
  **Port lifetime**: the callback port is held for the login and nothing longer. It is bound when the login window opens and released when the login ends — by success, by failure, by timeout, or by cancellation — and the returned promise settles only after the socket is actually free. An error therefore always means the port is already available, and the port is released *before* the authorization code is exchanged for a token, so a slow identity provider cannot hold it either.
347
552
 
348
- **Timeout**: an interactive login waits 30 seconds for its callback. This applies to the browser, OIDC and SAML flows alike; before 1.2.0 the OIDC and SAML flows had no timeout at all, so an abandoned login held its port for the life of the process.
553
+ **Timeout**: an interactive login waits 30 seconds for its callback, adjustable with `timeoutMs`. This applies to the browser, OIDC and SAML flows alike; before 1.2.0 the OIDC and SAML flows had no timeout at all, so an abandoned login held its port for the life of the process.
349
554
 
350
- **Cancellation**: `ICallbackServerOptions.signal` accepts an `AbortSignal`, honoured before the bind, during it, and while waiting.
555
+ **Incomplete callbacks**: a `/callback` carrying neither a code nor an error no longer ends the login. It is answered, counted, and the tally is reported if the login later times out — so a browser prefetch or a stray probe cannot cancel a login the user is still completing.
351
556
 
352
- **Process termination**: the callback server no longer installs its own `SIGTERM` / `SIGINT` / `SIGHUP` / `exit` handlers. A terminating process releases its listening sockets to the operating system anyway — measured at 0-1 ms after the process disappears — and the handlers were part of the cleanup tangle this release removes. If a client kills the process mid-login, the port comes back with the process.
557
+ **Cancellation**: pass `signal` to the strategy, or call `dispose()` on it. Both are honoured before the bind, during it, and while waiting; `dispose()` resolves only once the socket is free.
558
+
559
+ **Process termination**: the callback server no longer installs its own `SIGTERM` / `SIGINT` / `SIGHUP` / `exit` handlers. A terminating process releases its listening sockets to the operating system anyway — measured at 0-1 ms after the process disappears — and the handlers were part of the cleanup tangle removed in 1.2.0. If a client kills the process mid-login, the port comes back with the process.
353
560
 
354
561
  **Cross-Platform Browser Support**: The browser authentication works across Linux, macOS, and Windows:
355
562
  - **Linux**: Automatically sets `DISPLAY=:0` if neither `DISPLAY` nor `WAYLAND_DISPLAY` environment variables are set. Supports multiple browser executable names (`google-chrome`, `google-chrome-stable`, `chromium`, `chromium-browser` for Chrome; `firefox`, `firefox-esr` for Firefox).
356
563
  - **Windows**: Uses proper `cmd /c start ""` syntax for reliable browser opening.
357
564
  - **macOS**: Uses native `open -a` command.
358
565
 
359
- **Headless Mode (SSH/Remote)**: For environments without a display (SSH sessions, Docker, CI/CD), use `browser: 'headless'`:
566
+ **Headless Mode (SSH/Remote)**: For environments without a display (SSH sessions, Docker, CI/CD), leave `browser` at its default or set it explicitly:
360
567
 
361
568
  ```typescript
569
+ const provider = new AuthorizationCodeProvider({
570
+ uaaUrl, clientId, clientSecret,
571
+ authorization: browserCallbackStrategy({ browser: 'headless' }),
572
+ });
573
+
362
574
  const result = await provider.getTokens();
363
575
  ```
364
576
 
365
- In headless mode, the authentication URL is logged and the server waits for the user to complete authentication manually. The user can open the URL on any machine and the callback will be received by the server.
577
+ In headless mode the authorization URL is shown — to the logger if there is one, to stderr otherwise — and the server waits for the user to complete authentication manually. The user can open the URL on any machine, and the callback reaches the server because it listens on all interfaces; the shipped UAA transport also prints where to paste the code if the redirect cannot reach back.
366
578
 
367
- **Browser Options**:
368
- - `'system'` (default): Opens system default browser
369
- - `'headless'`: Logs URL, waits for manual callback (SSH/remote)
370
- - `'none'`: Logs URL, immediately rejects (automated tests)
371
- - `'chrome'`, `'edge'`, `'firefox'`: Opens specific browser
579
+ **Browser Options** (`browserCallbackStrategy({ browser })`):
580
+ - `'none'` (default): Shows the URL, waits for the callback or a paste
581
+ - `'headless'`: Same as `'none'`
582
+ - `'system'`: Opens the system default browser
583
+ - `'auto'`: Tries to open a browser; on failure the URL is shown and the login continues
584
+ - `'chrome'`, `'edge'`, `'firefox'`: Opens a specific browser
372
585
 
373
586
  ### Token Validation
374
587
 
@@ -460,6 +673,102 @@ try {
460
673
 
461
674
  All error codes are defined in `@mcp-abap-adt/interfaces` package as `TOKEN_PROVIDER_ERROR_CODES`.
462
675
 
676
+ ## Migrating from 1.x to 2.0
677
+
678
+ Every field that described *how* an interactive login is conducted is gone from
679
+ the provider configs, replaced by a single `authorization` strategy.
680
+
681
+ | 1.x field | 2.0 |
682
+ |---|---|
683
+ | `browser: 'system'` | `authorization: browserCallbackStrategy({ browser: 'system' })` |
684
+ | `browser: 'system'`, `redirectPort: 4001` | `authorization: browserCallbackStrategy({ browser: 'system', port: 4001 })` |
685
+ | `redirectUri: uri` (OIDC) | `redirectUri` on the strategy — the strategy owns it |
686
+ | `authorizationCode: 'abc'` (OIDC) | `authorization: asOidcResult(staticCodeStrategy({ redirectUri, payload: 'abc' }))` |
687
+ | `authorizationCodeProvider: fn` (OIDC) | `authorization: asOidcResult(externalCodeStrategy({ redirectUri, provide: fn }))` |
688
+ | `assertionFlow: 'browser'` (SAML) | `authorization: samlCallbackStrategy()` — or omit `authorization` entirely |
689
+ | `assertionFlow: 'manual'`, `manualInput: fn` (SAML) | `authorization: manualSamlResponseStrategy({ redirectUri: acsUrl, read: fn })` |
690
+ | `assertionFlow: 'assertion'`, `assertionProvider: fn` (SAML) | `authorization: externalCodeStrategy({ redirectUri: acsUrl, provide: fn })` |
691
+
692
+ Four things in that table are easy to get wrong.
693
+
694
+ **The default callback port changed from 3001 to 61001** — for the UAA flow and
695
+ for SAML alike, the latter because the SAML ACS used to default to
696
+ `http://localhost:3001/callback` and now comes from the strategy. If you relied
697
+ on the default and registered `http://localhost:3001/callback` with your
698
+ identity provider, **the IdP rejects the redirect**, so the error you see is
699
+ foreign and says nothing about this package. Either register the new URI, or
700
+ keep the old one with one line:
701
+
702
+ ```ts
703
+ authorization: browserCallbackStrategy({ browser: 'system', port: 3001 })
704
+ ```
705
+
706
+ (61001 was chosen because it sits above Linux's `ip_local_port_range`, so an
707
+ outbound connection never squats on it, and well away from the 3001/3333 range
708
+ that servers and proxies in this family use.)
709
+
710
+ **`redirectUri` is not optional in the SAML manual and assertion migrations.**
711
+ The rows above show it for a reason: `manualSamlResponseStrategy` and
712
+ `externalCodeStrategy` default their redirect URI to
713
+ `http://localhost:61001/callback`, and both SAML providers require the ACS they
714
+ were told about to match the URI the strategy names. Declare a real `acsUrl`,
715
+ omit `redirectUri`, and the login fails the guard before anything opens:
716
+
717
+ ```
718
+ SAML acsUrl is https://sp.example.com/saml/acs, but the authorization strategy
719
+ is listening on http://localhost:61001/callback. They must match.
720
+ ```
721
+
722
+ Pass `redirectUri: acsUrl` and it works. (Declaring neither leaves both at the
723
+ default, which is consistent but only useful when the IdP posts to localhost.)
724
+
725
+ **`asOidcResult` is required for `OidcBrowserProvider`.** It takes
726
+ `IAuthorizationStrategy<OidcCallbackResult>`; `staticCodeStrategy`,
727
+ `externalCodeStrategy` and `manualPasteStrategy` yield a `string`. The obvious
728
+ one-line migration does not type-check without the adapter:
729
+
730
+ ```ts
731
+ // 1.x
732
+ new OidcBrowserProvider({ clientId, tokenEndpoint, authorizationEndpoint,
733
+ authorizationCode: 'abc', redirectUri: 'urn:ietf:wg:oauth:2.0:oob' });
734
+
735
+ // 2.0
736
+ const redirectUri = 'urn:ietf:wg:oauth:2.0:oob';
737
+ new OidcBrowserProvider({ clientId, tokenEndpoint, authorizationEndpoint,
738
+ authorization: asOidcResult(staticCodeStrategy({ redirectUri, payload: 'abc' })) });
739
+
740
+ // 2.0, code fetched by your own flow
741
+ new OidcBrowserProvider({ clientId, tokenEndpoint, authorizationEndpoint,
742
+ authorization: asOidcResult(externalCodeStrategy({ redirectUri, provide: fetchCode })) });
743
+ ```
744
+
745
+ `samlCallbackStrategy` needs no adapter: SAML strategies yield a string and the
746
+ SAML providers take a string.
747
+
748
+ **`acsUrl` is now required whenever `authorizationUrl` is set** on either SAML
749
+ provider, and is rejected at construction rather than at login. 1.x accepted the
750
+ combination and silently defaulted the ACS to `http://localhost:3001/callback`;
751
+ since the real ACS is buried in a deflated `SAMLRequest` this package did not
752
+ build, it cannot be inferred and must be declared.
753
+
754
+ Three more changes that are not fields:
755
+
756
+ - **The terminal-paste channel is gone from the browser strategy.** In 1.x a
757
+ `none` / `headless` login also accepted the code on stdin, without the
758
+ consumer choosing anything. `browserCallbackStrategy` no longer reads stdin at
759
+ all — under a stdio RPC transport that stream carries the protocol. If your
760
+ users pasted codes into the terminal, switch that flow to
761
+ `manualPasteStrategy({ redirectUri, read })`, which is the same capability as
762
+ an explicit choice; otherwise the paste form on `/` is the remaining fallback
763
+ for a browser on another machine.
764
+ - **Device flow prompts no longer go to stdout.** `DeviceFlowProviderConfig`
765
+ accepts `logger?: ILogger`; the verification URI and user code go to that
766
+ logger, or to stderr when there is none. Anything that captured stdout to read
767
+ the device code must read stderr or supply a logger.
768
+ - **A `/callback` carrying neither a code nor an error no longer ends the
769
+ login.** It is answered and counted, and the tally appears in the timeout
770
+ message if the login later expires.
771
+
463
772
  ## Testing
464
773
 
465
774
  The package includes both unit tests (with mocks) and integration tests (with real files and services).
@@ -500,8 +809,8 @@ Integration tests will skip if `test-config.yaml` is not configured or contains
500
809
  **Note**:
501
810
  - Integration tests use `AbapServiceKeyStore` and `AbapSessionStore` for loading service keys and sessions
502
811
  - Tests may open a browser for authentication if no refresh token is available. This is expected behavior.
503
- - Each test scenario uses a unique port (3101, 3102, 3103) to avoid port conflicts
504
- - Tests use `browser: 'system'` for interactive authentication (not `'none'`)
812
+ - The interactive test asks the OS for a free port rather than pinning one, so it cannot collide with a running server
813
+ - Tests use `browserCallbackStrategy({ browser: 'system' })` for interactive authentication (not `'none'`)
505
814
 
506
815
  ### Debug Logging
507
816
 
@@ -549,11 +858,13 @@ Example output:
549
858
 
550
859
  ## Dependencies
551
860
 
552
- - `@mcp-abap-adt/interfaces` (^0.2.2) - Interface definitions and error code constants
861
+ - `@mcp-abap-adt/interfaces` (^11.6.0) - Interface definitions (`ITokenProvider`, `IAuthorizationStrategy`, `CallbackServerFactory`) and error code constants
553
862
  - `axios` - HTTP client
554
863
  - `express` - OAuth2 callback server
555
864
  - `open` - Browser opening utility
556
865
 
866
+ Requires Node.js `>=18.2.0`.
867
+
557
868
  ## License
558
869
 
559
870
  MIT
@@ -10,6 +10,7 @@ import {
10
10
  ABAP_CONNECTION_VARS,
11
11
  } from '@mcp-abap-adt/auth-stores';
12
12
  import { AuthorizationCodeProvider } from '../src/providers/AuthorizationCodeProvider';
13
+ import { browserCallbackStrategy } from '../src/strategies';
13
14
  import {
14
15
  getUaaCredentials,
15
16
  parseEnvFile,
@@ -89,14 +90,15 @@ Example:
89
90
  authorizationUrl = `${uaaUrl}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code`;
90
91
  }
91
92
 
92
- // Create provider
93
+ // Create provider. `--port` now reaches the strategy that binds the socket;
94
+ // it must stay the port baked into the authorization URL above, or the
95
+ // provider rejects the mismatch before opening anything.
93
96
  const provider = new AuthorizationCodeProvider({
94
97
  authorizationUrl,
95
98
  uaaUrl,
96
99
  clientId,
97
100
  clientSecret,
98
- browser,
99
- redirectPort: port,
101
+ authorization: browserCallbackStrategy({ browser, port }),
100
102
  refreshToken: existingRefreshToken,
101
103
  });
102
104