@mcp-abap-adt/auth-providers 1.1.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.
- package/CHANGELOG.md +121 -0
- package/README.md +376 -61
- package/bin/auth-authorization-code.ts +5 -3
- package/dist/__tests__/helpers/netHelpers.d.ts +16 -0
- package/dist/__tests__/helpers/netHelpers.d.ts.map +1 -1
- package/dist/__tests__/helpers/netHelpers.js +29 -0
- package/dist/auth/announce.d.ts +11 -0
- package/dist/auth/announce.d.ts.map +1 -0
- package/dist/auth/announce.js +19 -0
- package/dist/auth/browserAuth.d.ts +22 -16
- package/dist/auth/browserAuth.d.ts.map +1 -1
- package/dist/auth/browserAuth.js +101 -536
- package/dist/auth/callbackServer.d.ts +46 -0
- package/dist/auth/callbackServer.d.ts.map +1 -0
- package/dist/auth/callbackServer.js +332 -0
- package/dist/auth/oidcBrowserAuth.d.ts +4 -3
- package/dist/auth/oidcBrowserAuth.d.ts.map +1 -1
- package/dist/auth/oidcBrowserAuth.js +25 -134
- package/dist/auth/saml2Auth.d.ts +2 -2
- package/dist/auth/saml2Auth.d.ts.map +1 -1
- package/dist/auth/saml2Auth.js +21 -141
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -1
- package/dist/providers/AuthorizationCodeProvider.d.ts +7 -3
- package/dist/providers/AuthorizationCodeProvider.d.ts.map +1 -1
- package/dist/providers/AuthorizationCodeProvider.js +51 -41
- package/dist/providers/DeviceFlowProvider.d.ts +2 -1
- package/dist/providers/DeviceFlowProvider.d.ts.map +1 -1
- package/dist/providers/DeviceFlowProvider.js +13 -10
- package/dist/providers/OidcBrowserProvider.d.ts +4 -7
- package/dist/providers/OidcBrowserProvider.d.ts.map +1 -1
- package/dist/providers/OidcBrowserProvider.js +54 -57
- package/dist/providers/OidcDeviceFlowProvider.d.ts.map +1 -1
- package/dist/providers/OidcDeviceFlowProvider.js +9 -7
- package/dist/providers/Saml2BearerProvider.d.ts +2 -2
- package/dist/providers/Saml2BearerProvider.d.ts.map +1 -1
- package/dist/providers/Saml2BearerProvider.js +3 -0
- package/dist/providers/Saml2PureProvider.d.ts +2 -2
- package/dist/providers/Saml2PureProvider.d.ts.map +1 -1
- package/dist/providers/Saml2PureProvider.js +3 -0
- package/dist/providers/saml2Utils.d.ts +12 -12
- package/dist/providers/saml2Utils.d.ts.map +1 -1
- package/dist/providers/saml2Utils.js +46 -26
- package/dist/strategies/BrowserCallbackStrategy.d.ts +64 -0
- package/dist/strategies/BrowserCallbackStrategy.d.ts.map +1 -0
- package/dist/strategies/BrowserCallbackStrategy.js +210 -0
- package/dist/strategies/asOidcResult.d.ts +11 -0
- package/dist/strategies/asOidcResult.d.ts.map +1 -0
- package/dist/strategies/asOidcResult.js +30 -0
- package/dist/strategies/codeStrategies.d.ts +22 -0
- package/dist/strategies/codeStrategies.d.ts.map +1 -0
- package/dist/strategies/codeStrategies.js +39 -0
- package/dist/strategies/index.d.ts +8 -0
- package/dist/strategies/index.d.ts.map +1 -0
- package/dist/strategies/index.js +18 -0
- package/dist/strategies/manualStrategies.d.ts +20 -0
- package/dist/strategies/manualStrategies.d.ts.map +1 -0
- package/dist/strategies/manualStrategies.js +77 -0
- package/package.json +4 -4
- package/dist/auth/manualInput.d.ts +0 -5
- package/dist/auth/manualInput.d.ts.map +0 -1
- 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 {
|
|
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
|
-
###
|
|
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.
|
|
104
163
|
|
|
105
|
-
|
|
164
|
+
```typescript
|
|
165
|
+
import { BrowserCallbackStrategy } from '@mcp-abap-adt/auth-providers';
|
|
106
166
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
167
|
+
const strategy = new BrowserCallbackStrategy<MyPayload>({
|
|
168
|
+
callbackServer: withMyOwnCallbackServer, // CallbackServerFactory<MyPayload>
|
|
169
|
+
port: 61001,
|
|
170
|
+
timeoutMs: 30000,
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
#### Bringing your own
|
|
175
|
+
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
194
|
+
#### Manual paste over a callback server
|
|
119
195
|
|
|
120
|
-
|
|
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
|
|
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>:<
|
|
125
|
-
|
|
126
|
-
since the callback server listens on all interfaces.
|
|
127
|
-
|
|
128
|
-
|
|
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:
|
|
129
213
|
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
```
|
|
229
|
+
|
|
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 {
|
|
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 (
|
|
288
|
+
OIDC browser example (a code you already hold + explicit endpoints):
|
|
167
289
|
|
|
168
290
|
```typescript
|
|
169
|
-
import {
|
|
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
|
-
|
|
176
|
-
|
|
303
|
+
authorization: asOidcResult(
|
|
304
|
+
staticCodeStrategy({ redirectUri, payload: '<paste-code-here>' }),
|
|
305
|
+
),
|
|
177
306
|
});
|
|
178
307
|
```
|
|
179
308
|
|
|
180
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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,30 +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
|
-
|
|
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.
|
|
550
|
+
|
|
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.
|
|
345
552
|
|
|
346
|
-
**Timeout**:
|
|
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.
|
|
347
554
|
|
|
348
|
-
**
|
|
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.
|
|
556
|
+
|
|
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.
|
|
349
560
|
|
|
350
561
|
**Cross-Platform Browser Support**: The browser authentication works across Linux, macOS, and Windows:
|
|
351
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).
|
|
352
563
|
- **Windows**: Uses proper `cmd /c start ""` syntax for reliable browser opening.
|
|
353
564
|
- **macOS**: Uses native `open -a` command.
|
|
354
565
|
|
|
355
|
-
**Headless Mode (SSH/Remote)**: For environments without a display (SSH sessions, Docker, CI/CD),
|
|
566
|
+
**Headless Mode (SSH/Remote)**: For environments without a display (SSH sessions, Docker, CI/CD), leave `browser` at its default or set it explicitly:
|
|
356
567
|
|
|
357
568
|
```typescript
|
|
569
|
+
const provider = new AuthorizationCodeProvider({
|
|
570
|
+
uaaUrl, clientId, clientSecret,
|
|
571
|
+
authorization: browserCallbackStrategy({ browser: 'headless' }),
|
|
572
|
+
});
|
|
573
|
+
|
|
358
574
|
const result = await provider.getTokens();
|
|
359
575
|
```
|
|
360
576
|
|
|
361
|
-
In headless mode
|
|
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.
|
|
362
578
|
|
|
363
|
-
**Browser Options
|
|
364
|
-
- `'
|
|
365
|
-
- `'headless'`:
|
|
366
|
-
- `'
|
|
367
|
-
- `'
|
|
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
|
|
368
585
|
|
|
369
586
|
### Token Validation
|
|
370
587
|
|
|
@@ -456,6 +673,102 @@ try {
|
|
|
456
673
|
|
|
457
674
|
All error codes are defined in `@mcp-abap-adt/interfaces` package as `TOKEN_PROVIDER_ERROR_CODES`.
|
|
458
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
|
+
|
|
459
772
|
## Testing
|
|
460
773
|
|
|
461
774
|
The package includes both unit tests (with mocks) and integration tests (with real files and services).
|
|
@@ -496,8 +809,8 @@ Integration tests will skip if `test-config.yaml` is not configured or contains
|
|
|
496
809
|
**Note**:
|
|
497
810
|
- Integration tests use `AbapServiceKeyStore` and `AbapSessionStore` for loading service keys and sessions
|
|
498
811
|
- Tests may open a browser for authentication if no refresh token is available. This is expected behavior.
|
|
499
|
-
-
|
|
500
|
-
- 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'`)
|
|
501
814
|
|
|
502
815
|
### Debug Logging
|
|
503
816
|
|
|
@@ -545,11 +858,13 @@ Example output:
|
|
|
545
858
|
|
|
546
859
|
## Dependencies
|
|
547
860
|
|
|
548
|
-
- `@mcp-abap-adt/interfaces` (^
|
|
861
|
+
- `@mcp-abap-adt/interfaces` (^11.6.0) - Interface definitions (`ITokenProvider`, `IAuthorizationStrategy`, `CallbackServerFactory`) and error code constants
|
|
549
862
|
- `axios` - HTTP client
|
|
550
863
|
- `express` - OAuth2 callback server
|
|
551
864
|
- `open` - Browser opening utility
|
|
552
865
|
|
|
866
|
+
Requires Node.js `>=18.2.0`.
|
|
867
|
+
|
|
553
868
|
## License
|
|
554
869
|
|
|
555
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
|
|