@mcp-abap-adt/auth-broker 3.0.4 → 4.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 (51) hide show
  1. package/CHANGELOG.md +448 -1
  2. package/README.md +723 -533
  3. package/dist/AuthBroker.d.ts +178 -29
  4. package/dist/AuthBroker.d.ts.map +1 -1
  5. package/dist/AuthBroker.js +494 -101
  6. package/dist/DestinationConfigError.d.ts +27 -0
  7. package/dist/DestinationConfigError.d.ts.map +1 -0
  8. package/dist/DestinationConfigError.js +36 -0
  9. package/dist/SessionWriter.d.ts +54 -0
  10. package/dist/SessionWriter.d.ts.map +1 -0
  11. package/dist/SessionWriter.js +135 -0
  12. package/dist/binding.d.ts +95 -0
  13. package/dist/binding.d.ts.map +1 -0
  14. package/dist/binding.js +207 -0
  15. package/dist/bindingOf.d.ts +36 -0
  16. package/dist/bindingOf.d.ts.map +1 -0
  17. package/dist/bindingOf.js +53 -0
  18. package/dist/destinations.d.ts +139 -0
  19. package/dist/destinations.d.ts.map +1 -0
  20. package/dist/destinations.js +429 -0
  21. package/dist/index.d.ts +7 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +11 -7
  24. package/dist/providers/ITokenProvider.d.ts +2 -1
  25. package/dist/providers/ITokenProvider.d.ts.map +1 -1
  26. package/dist/providers/index.d.ts +2 -2
  27. package/dist/providers/index.js +2 -2
  28. package/dist/stores/index.d.ts +2 -2
  29. package/dist/stores/index.js +2 -2
  30. package/dist/stores/interfaces.d.ts +4 -3
  31. package/dist/stores/interfaces.d.ts.map +1 -1
  32. package/dist/stores/interfaces.js +2 -2
  33. package/dist/types.d.ts +5 -2
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js +3 -1
  36. package/package.json +25 -45
  37. package/CONTRIBUTORS.md +0 -33
  38. package/dist/__tests__/helpers/configHelpers.d.ts +0 -59
  39. package/dist/__tests__/helpers/configHelpers.d.ts.map +0 -1
  40. package/dist/__tests__/helpers/configHelpers.js +0 -206
  41. package/dist/__tests__/helpers/netHelpers.d.ts +0 -3
  42. package/dist/__tests__/helpers/netHelpers.d.ts.map +0 -1
  43. package/dist/__tests__/helpers/netHelpers.js +0 -63
  44. package/dist/__tests__/helpers/testLogger.d.ts +0 -7
  45. package/dist/__tests__/helpers/testLogger.d.ts.map +0 -1
  46. package/dist/__tests__/helpers/testLogger.js +0 -54
  47. package/dist/bin/mcp-auth.js +0 -752
  48. package/dist/bin/mcp-sso.js +0 -825
  49. package/dist/bin/mcpSsoConfig.js +0 -566
  50. package/dist/bin/samlMetadata.js +0 -175
  51. package/dist/bin/workDir.js +0 -79
package/README.md CHANGED
@@ -1,24 +1,39 @@
1
1
  # @mcp-abap-adt/auth-broker
2
2
  [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://stand-with-ukraine.pp.ua)
3
3
 
4
- A per-destination token broker for SAP BTP and ABAP systems. For a destination — a name, such as
5
- `TRIAL` — it reads the session and the service key from the stores it is given, hands them to
6
- a token provider, and saves what the provider returns back to the session: a JWT or, for SAML,
7
- session cookies, with the refresh token. It decides nothing about tokens itself: whether the
8
- cached token is still good, when to refresh and when to log in is the provider's call
9
- (`@mcp-abap-adt/auth-providers`), and where sessions live is the stores' (`@mcp-abap-adt/auth-stores`).
10
-
11
- It also ships two CLIs that write session files: `mcp-auth` (service key → session,
12
- authorization code or client credentials) and `mcp-sso` (OIDC and SAML single sign-on).
4
+ A per-destination credential broker for SAP BTP and ABAP systems. For a destination — a name,
5
+ such as `TRIAL` — `getProvider` builds the `IAuthProvider` the destination states (basic, SNC, a
6
+ UAA, OIDC or SAML grant, or a credential handed over) from the *means* in the service key store
7
+ and the *secret* in the session store, ready for a `@mcp-abap-adt/connection` 10 connector, and
8
+ stores back every token or set of SAML session cookies that provider obtains or renews. The
9
+ token API (`getToken`, `refreshToken`, `createTokenRefresher`) serves whoever wants a token and
10
+ nothing else. It decides nothing about tokens itself: whether the cached token is still good,
11
+ when to refresh and when to log in is the provider's call (`@mcp-abap-adt/auth-providers`), and
12
+ where means and secrets live is the stores' (`@mcp-abap-adt/auth-stores`, or your own).
13
+
14
+ **Upgrading from 3.x?** See [*Migrating from 3.x*](#migrating-from-3x) — 4.0.0 needs a session
15
+ store that takes the secret alone (auth-stores 3 or later), means stated in a key store, and
16
+ the contracts of `interfaces-auth` 3.
17
+
18
+ The `mcp-auth` and `mcp-sso` commands that write destination files are in
19
+ [`@mcp-abap-adt/auth-broker-cli`](../auth-broker-cli/README.md) (2.0.0, on this
20
+ version), in the same repository. Up to 3.0.4 they shipped in this package; from 3.1.0 this package
21
+ has no `bin`. Install the commands with
22
+ `npm i -g @mcp-abap-adt/auth-broker-cli` — after
23
+ `npm uninstall -g @mcp-abap-adt/auth-broker` if you had installed this one
24
+ globally for them.
13
25
 
14
26
  ## Features
15
27
 
16
- - 🎯 **Per destination**: one provider per destination name, built by a factory or given once
28
+ - 🔌 **A credential for a connector**: `getProvider(destination)` builds the `IAuthProvider` the destination states — basic, SNC, the UAA grants (authorization code, client credentials, passcode), the OIDC grants (authorization code with PKCE, device code, password, token exchange), the SAML grants (session cookies, bearer token), or a credential handed over — from the service key store's means and the session store's secret
29
+ - 💾 **What a provider obtains is stored**: every token or set of SAML session cookies a `getProvider` provider obtains or renews — at `prepare()`, on expiry, or after a 401 in `rejected()` — is written to the session store, the secret alone with what it is bound to; a failed write is retried by the broker, and `flush()` tells you whether everything landed
30
+ - 🔒 **A secret goes only where it was obtained**: a stored token is used only for the resource it was obtained for, from the issuer and client that issued it — otherwise it is discarded and the provider logs in afresh
31
+ - 🎯 **Per destination**: one provider per destination name, shared by `getProvider` and the token API — one token, one refresh token, one renewal in flight
17
32
  - 🔄 **Provider-driven token lifecycle**: The provider decides whether its cached token is still good, refreshes it, or logs in; the broker persists what it returns
33
+ - 🪙 **A token API for whoever wants a token and nothing else**: `getToken`, `refreshToken` and `createTokenRefresher` on the destination's own provider, or on one you give the broker, as in 3.x
18
34
  - ⚡ **Forced refresh**: `refreshToken()` obtains a new token even when the cached one looks valid — for a caller holding a 401
19
35
  - 🧾 **JWT or SAML cookies**: what the provider returns is saved as a token or as session cookies
20
36
  - 🔑 **No secrets copied**: The client secret stays in the service key; the session store gets tokens only
21
- - 🧰 **CLIs**: `mcp-auth` and `mcp-sso` produce `.env`/JSON session files, SAML trust read from metadata
22
37
 
23
38
  ## Installation
24
39
 
@@ -34,10 +49,13 @@ supported as well.
34
49
 
35
50
  ### Basic Usage
36
51
 
37
- The broker takes a session store, an optional service key store, and a token
38
- provider implementing `IRefreshableTokenProvider` (from
52
+ This is the token API with a provider of your own — the 3.x way, which keeps
53
+ working. The broker takes a session store, an optional service key store, and
54
+ a token provider implementing `IRefreshableTokenProvider` (from
39
55
  `@mcp-abap-adt/interfaces-auth`) — or a factory that builds one per
40
- destination:
56
+ destination. Without a `provider` the token API serves what the destination
57
+ states, on the provider `getProvider` builds (see *Getting Tokens*); for a
58
+ `@mcp-abap-adt/connection` 10 connector, use `getProvider` itself.
41
59
 
42
60
  ```typescript
43
61
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
@@ -81,18 +99,358 @@ The factory receives:
81
99
  - `connConfig` — the session's connection config, with `serviceUrl` resolved
82
100
  (from the session, else the service key) and the token stored last.
83
101
 
102
+ These are 3.x's reads, in 3.x's order, kept for this path: a session store
103
+ that still answers a client or a URL is read first. auth-stores 3's session
104
+ stores answer neither, so the key store's are what is found. What the broker
105
+ writes back is the session secret alone (see *Getting Tokens*).
106
+
84
107
  A provider instance can be passed instead of a factory; it is then used as
85
108
  given, for every destination, and the broker seeds it with nothing:
86
109
 
87
110
  ```typescript
88
111
  const broker = new AuthBroker({
89
112
  sessionStore: new AbapSessionStore('/path/to/sessions'),
90
- provider: new AuthorizationCodeProvider({ uaaUrl, clientId, clientSecret }),
113
+ provider: new AuthorizationCodeProvider({
114
+ uaaUrl, clientId, clientSecret,
115
+ authorization: browserCallbackStrategy({ browser: 'system' }),
116
+ }),
91
117
  });
92
118
  ```
93
119
 
94
- > `AuthorizationCodeProvider` and the other `@mcp-abap-adt/auth-providers`
95
- > providers implement `IRefreshableTokenProvider` from auth-providers 4.2.0.
120
+ > `AuthorizationCodeProvider` and the other `@mcp-abap-adt/auth-providers` 5
121
+ > providers implement `IRefreshableTokenProvider` (from `@mcp-abap-adt/interfaces-auth`
122
+ > 3). Since auth-providers 5 the interactive ones take their `authorization`
123
+ > strategy explicitly — none is built for you.
124
+
125
+ ### A Provider for a Connector: `getProvider`
126
+
127
+ `getProvider(destination)` returns the `IAuthProvider` (from
128
+ `@mcp-abap-adt/interfaces-auth` 3) that a `@mcp-abap-adt/connection` 10
129
+ connector takes as it is. The destination states which provider it gets; the
130
+ broker reads two stores, each for one role:
131
+
132
+ - **the means** — `authType`, `grantType`, basic's user and password, the SNC,
133
+ OIDC and SAML fields, `serviceUrl`, the client — from the **service key store**
134
+ (`getConnectionConfig`), and only from there;
135
+ - **the secret** — the token or session cookies, `expiresAt`, the refresh
136
+ token — from the **session store** (`loadSession`), and only from there.
137
+
138
+ Means a session store happens to answer are not read, and neither is a token
139
+ or cookies a key store happens to answer. Nothing is inferred: the
140
+ destination's `authType` (and `grantType`, for `jwt` and `saml`) decides the
141
+ provider, never which other fields are present.
142
+
143
+ ```typescript
144
+ import { AuthBroker } from '@mcp-abap-adt/auth-broker';
145
+
146
+ const broker = new AuthBroker({
147
+ serviceKeyStore: myKeyStore, // the means — required by getProvider
148
+ sessionStore: mySessionStore, // the secret
149
+ // no `provider`: that option is the token API's source, not getProvider's
150
+ });
151
+
152
+ const provider = await broker.getProvider('DEV'); // an IAuthProvider
153
+ // const connection = createAbapConnection({ url, provider }, logger) …
154
+ ```
155
+
156
+ | `authType` / `grantType` | Provider (auth-providers 5.2) | Read from the key store | Read from the session store |
157
+ |---|---|---|---|
158
+ | `basic` (no grant read) | `new BasicAuthProvider(username, password)` | `username`, `password` | nothing |
159
+ | `snc` (no grant read) | `SncLogonProvider.forSecureLoginClient({ partnerName, qop, sncLib, myName, logger })` | `sncPartnerName` (required); `sncQop`, `sncLib`, `sncMyName` when set | nothing |
160
+ | `jwt` / `authorization_code` | `AuthorizationCodeProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret`; `serviceUrl`, `sapClient` for the binding | the seed: `authorizationToken`, `refreshToken`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
161
+ | `jwt` / `client_credentials` | `ClientCredentialsProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret`; `serviceUrl`, `sapClient` for the binding | nothing (the row takes the client alone) |
162
+ | `jwt` / `passcode` | `UaaPasscodeProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret` (`''` = a public client); `serviceUrl`, `sapClient` for the binding | the seed: `authorizationToken`, `refreshToken`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
163
+ | `jwt` / `oidc_authorization_code` | `OidcBrowserProvider` (PKCE) | `uaaClientId`, `uaaClientSecret` (`''` = a public client); `oidcIssuerUrl`, or `oidcAuthorizationEndpoint` + `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` for the binding | the seed — used only when `issuedFor` and `issuedBy` match |
164
+ | `jwt` / `device_code` | `OidcDeviceFlowProvider` | the client as above; `oidcIssuerUrl`, or `oidcDeviceAuthorizationEndpoint` + `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` | the seed, as above |
165
+ | `jwt` / `password` | `OidcPasswordProvider` | the client as above; `username`, `password`; `oidcIssuerUrl`, or `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` | the seed, as above |
166
+ | `jwt` / `token_exchange` | `OidcTokenExchangeProvider` (RFC 8693) | the client as above; `oidcSubjectToken`, `oidcSubjectTokenType`; `oidcAudience`, `oidcActorToken`, `oidcActorTokenType` when set; `oidcScopes`, joined by one space into its `scope`; `oidcIssuerUrl`, or `oidcTokenEndpoint`; `serviceUrl`, `sapClient` | the seed, as above (it has no refresh grant: a renewal exchanges again) |
167
+ | `saml` / `saml2_pure` | `Saml2PureProvider` | `samlIdpSsoUrl`, `samlSpEntityId`, `samlIdpEntityId` (the expected issuer), `samlIdpCertificates`; `samlAcsUrl`, `samlRelayState`, `samlIdpInitiated`, `samlClockSkewMs` when set; `serviceUrl`, `sapClient` for the binding | the seed: `sessionCookies`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
168
+ | `saml` / `saml2_bearer` | `Saml2BearerProvider` (RFC 7522) | the same SAML fields; `samlTokenUrl` when set; the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret` (`''` = a public client); `serviceUrl`, `sapClient` | the seed: `authorizationToken`, `refreshToken`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
169
+ | `jwt` / `none` | `TokenAuthProvider.fixed(authorizationToken)` | `authType`, `grantType`, `serviceUrl` (+ `sapClient`); an issuer, when stated: `oidcIssuerUrl`, or the client's `uaaUrl` + `uaaClientId` | `authorizationToken` (required), `issuedFor` (required to match); `issuedBy` when the means state an issuer |
170
+ | `saml` / `none` | `new SamlAuthProvider(sessionCookies)` | `authType`, `grantType`, `serviceUrl` (+ `sapClient`); `samlAcsUrl` when stated | `sessionCookies` (required), `issuedFor` (required to match); `issuedBy` when `samlAcsUrl` is stated |
171
+
172
+ **No provider reads `serviceUrl`.** The URL of the system is where the
173
+ connector connects, not authorization data: no token provider reads it — a
174
+ UAA grant needs the client and `uaaUrl` — so `getProvider` neither requires it
175
+ nor passes it to a provider. Give the connector its URL from your key store
176
+ (`getConnectionConfig`), as the key store answers it. The broker reads it,
177
+ with `sapClient`, for one thing only: to bind a stored secret to the resource
178
+ it was obtained for (see *A Secret Is Bound to Its Resource and Issuer*). A
179
+ token destination without it still gets its provider, but no stored secret
180
+ is reused for it.
181
+
182
+ `none` is how a handed-over credential is stated: the key store says
183
+ `grantType: 'none'`, and the token or cookies live in the session. The SNC
184
+ row takes the contract's own defaults when a field is absent — the library is
185
+ discovered (`SNC_LIB_64`, `SNC_LIB`, the Secure Login Client's install path)
186
+ when `sncLib` is, the user's SNC name comes from the credential when
187
+ `sncMyName` is, and `qop` is the provider's `'9'` when `sncQop` is.
188
+
189
+ The allowed pairs are `jwt` with `authorization_code`, `client_credentials`,
190
+ `passcode`, `oidc_authorization_code`, `device_code`, `password`,
191
+ `token_exchange` or `none`, and `saml` with `saml2_pure`, `saml2_bearer` or
192
+ `none`. A pair outside them is a `DestinationConfigError` naming `grantType`.
193
+
194
+ **The UAA grants.** The client comes from the key store's
195
+ `getAuthorizationConfig` — never from the session store. A session is the
196
+ seed: the stored token is presented while it is valid (a JWT's own `exp`
197
+ decides; the stored `expiresAt` serves a token that carries none), and the
198
+ stored refresh token renews it. Without a session the provider is built
199
+ unseeded and obtains its first token at `prepare()`. `uaaClientSecret: ''` is
200
+ a public client: `passcode` takes it as no secret; `AuthorizationCodeProvider`
201
+ and `ClientCredentialsProvider` require a secret, so for them `''` is missing.
202
+
203
+ **The `authorization` option** is the interactive half of
204
+ `authorization_code` and `passcode`: a function of the destination and the
205
+ grant, returning the `IAuthorizationStrategy<string>` the provider logs in
206
+ with. The broker calls it once, when it builds that destination's provider,
207
+ and never disposes what it returns — whoever constructs, disposes. For
208
+ `passcode` the strategy is handed `<uaaUrl>/passcode` as the URL to send the
209
+ user to, and returns the code. There is no default: a destination whose grant
210
+ needs it, without it, is a `DestinationConfigError` naming `authorization`.
211
+ A headless process passes one that refuses (see *Headless Processes*).
212
+
213
+ ```typescript
214
+ import { browserCallbackStrategy, manualPasscodeStrategy } from '@mcp-abap-adt/auth-providers';
215
+
216
+ const broker = new AuthBroker({
217
+ serviceKeyStore: myKeyStore,
218
+ sessionStore: mySessionStore,
219
+ authorization: (destination, grant) =>
220
+ grant === 'passcode'
221
+ ? manualPasscodeStrategy({ timeoutMs: 300_000 })
222
+ : browserCallbackStrategy({ timeoutMs: 120_000 }),
223
+ });
224
+ ```
225
+
226
+ **The OIDC grants.** The client is the key store's `getAuthorizationConfig`
227
+ again: `uaaClientId`, and `uaaClientSecret` — `''` is a public client, sent
228
+ with no secret. (A key store answers a client when it holds one: from
229
+ auth-stores 3.2.0, `EnvDestinationStore` answers one whenever `uaaClientId`
230
+ is stated, a missing `uaaUrl` or secret as `''` — so an OIDC destination
231
+ states its issuer in `oidcIssuerUrl` alone, and a public client no secret.)
232
+ The endpoints come from `oidcIssuerUrl`, which the provider discovers them
233
+ from, or — without it — from every explicit endpoint the row reads
234
+ (`oidcAuthorizationEndpoint`, `oidcDeviceAuthorizationEndpoint`,
235
+ `oidcTokenEndpoint`); without either the error names `oidcIssuerUrl` and the
236
+ endpoints missing. `oidcScopes` is passed as given (`token_exchange` takes
237
+ one `scope` string: the scopes joined by a space). The subject and actor
238
+ tokens of `token_exchange` and the user and password of `password` are
239
+ means: sent to the token endpoint, never written to the session.
240
+
241
+ **The SAML grants.** Each provider validates the assertion before anything
242
+ uses it, with a validator the broker composes from the destination's trust
243
+ and your replay store: `createSignedResponseValidator` for `saml2_pure` (the
244
+ Response must be signed — the cookies' system receives it whole) and
245
+ `createSignedAssertionValidator` for `saml2_bearer` (the Assertion must be
246
+ signed — the token endpoint receives it alone), from `samlIdpCertificates`
247
+ (PEM or base64 DER; several during a rotation), `samlClockSkewMs` and
248
+ `assertionReplayStore(destination)`; `samlIdpEntityId` is the issuer every
249
+ assertion must name. A certificate the validator cannot read, or a
250
+ `samlClockSkewMs` that is not a whole, non-negative number of milliseconds,
251
+ is a `DestinationConfigError` naming the field. `samlIdpInitiated: true`
252
+ declares an IdP-initiated login: no AuthnRequest, and an assertion carrying
253
+ no `InResponseTo` — your strategy then hands over the SAMLResponse without
254
+ asking for an authorization URL (a strategy that asks gets Oops from
255
+ `prepare()`). `saml2_pure` is seeded with the stored cookies and their
256
+ `expiresAt` — cookies carry no expiry of their own, so the provider keeps them
257
+ until the assertion's earliest `NotOnOrAfter` (less the provider's one-minute
258
+ margin: an identity provider whose assertions live a minute or less makes
259
+ every request a login); SAML has no refresh token, so a renewal is a new
260
+ login through your strategy. `saml2_bearer` posts the Assertion to
261
+ `samlTokenUrl`, else `<uaaUrl>/oauth/token`, with the client, and renews by
262
+ its refresh token.
263
+
264
+ **The collaborator options** — each a function of the destination, called
265
+ once when that destination's provider is built, never disposed by the
266
+ broker, and required only by the rows that use it; a row whose option is
267
+ missing is a `DestinationConfigError` naming it:
268
+
269
+ | Option | Rows | What it returns |
270
+ |---|---|---|
271
+ | `authorization(destination, grant)` | `authorization_code`, `passcode`, `saml2_pure`, `saml2_bearer` (the grant is passed) | the `IAuthorizationStrategy<string>` that conducts the login — for the SAML grants, the one that returns the SAMLResponse (`samlCallbackStrategy`, `manualSamlResponseStrategy`, …) |
272
+ | `oidcAuthorization(destination)` | `oidc_authorization_code` | an `IAuthorizationStrategy<OidcCallbackResult>` (`oidcCallbackStrategy`, or `asOidcResult(…)` over a string strategy) |
273
+ | `deviceCodePresenter(destination)` | `device_code` | an `IDeviceCodePresenter` that shows the user the verification URL and code (`consoleDeviceCodePresenter(logger)`, or your UI) |
274
+ | `samlCookies(destination)` | `saml2_pure` | `(samlResponse) => Promise<string>`: posts the validated SAMLResponse to the system's ACS and returns the session cookies it sets |
275
+ | `assertionReplayStore(destination)` | `saml2_pure`, `saml2_bearer` | the `IAssertionReplayStore` the validator records each assertion in, refusing one presented twice — `defaultReplayStore` (process-wide, in memory) or a shared one of yours |
276
+
277
+ ```typescript
278
+ import {
279
+ consoleDeviceCodePresenter,
280
+ defaultReplayStore,
281
+ oidcCallbackStrategy,
282
+ samlCallbackStrategy,
283
+ } from '@mcp-abap-adt/auth-providers';
284
+
285
+ const broker = new AuthBroker(
286
+ {
287
+ serviceKeyStore: myKeyStore,
288
+ sessionStore: mySessionStore,
289
+ authorization: (destination, grant) =>
290
+ grant === 'saml2_pure' || grant === 'saml2_bearer'
291
+ ? samlCallbackStrategy({ timeoutMs: 120_000 })
292
+ : browserCallbackStrategy({ timeoutMs: 120_000 }),
293
+ oidcAuthorization: () => oidcCallbackStrategy({ timeoutMs: 120_000 }),
294
+ deviceCodePresenter: () => consoleDeviceCodePresenter(logger),
295
+ samlCookies: (destination) => (samlResponse) =>
296
+ postToAcs(destination, samlResponse), // yours: the system's ACS answers Set-Cookie
297
+ assertionReplayStore: () => defaultReplayStore,
298
+ },
299
+ logger,
300
+ );
301
+ ```
302
+
303
+ ### A Secret Is Bound to Its Resource and Issuer
304
+
305
+ A token's audience, cookies' host and path, a system's client: presenting a
306
+ secret to a resource it was not obtained for is a leak, and a secret from
307
+ another authorization server or client must not stand in for this
308
+ destination's. So the session store keeps, beside the secret, two strings
309
+ (`IConnectionConfig`, `@mcp-abap-adt/interfaces-auth-broker` 1.1.0):
310
+
311
+ - **`issuedFor`** — the resource: `serviceUrl` with the SAP client, e.g.
312
+ `https://my-abap.example.com:443/sap/bc/adt?sap-client=100`;
313
+ - **`issuedBy`** — who issued it, to which client: `uaaUrl` with
314
+ `client_id=<uaaClientId>` for the UAA grants and `saml2_bearer`, e.g.
315
+ `https://sub.authentication.us10.hana.ondemand.com:443?client_id=sb-abap-trial`;
316
+ `oidcIssuerUrl` — else `uaaUrl` — with `client_id=<uaaClientId>` for the
317
+ OIDC grants; for `saml2_pure`'s cookies, the ACS of the system that set
318
+ them: `samlAcsUrl`, origin and path.
319
+
320
+ The broker computes both from the destination's means and compares them,
321
+ canonicalised on **both** sides, with what the session holds:
322
+
323
+ - **The canonical form:** scheme and host lower-cased; the port explicit
324
+ (`443` for `https`, `80` for `http`); the path without a trailing `/` (the
325
+ root path is empty); one query parameter at most — `sap-client` (the means'
326
+ `sapClient` wins over a `sap-client` already in `serviceUrl`) or
327
+ `client_id` — parsed and re-encoded, so a value percent-encoded on one side
328
+ and plain on the other compares equal; no user info, no other parameter,
329
+ no fragment. A URL that does not parse binds nothing. A store keeps the
330
+ strings as given and need not canonicalise them.
331
+ - **The grants that obtain a secret** (UAA, OIDC, SAML)**:** a stored secret seeds the provider only when **both**
332
+ stored values equal the computed ones. Otherwise — either different, or
333
+ absent on either side (no `serviceUrl` in the means, an OIDC destination
334
+ stating neither `oidcIssuerUrl` nor `uaaUrl`, a `saml2_pure` one without
335
+ `samlAcsUrl`, a session written without them) — the secret is not used,
336
+ **refresh token included**: the
337
+ provider is built as with no session and logs in afresh by its grant, and
338
+ the log says only `<destination>: secret bound to another resource,
339
+ discarded` — never a URI, never a token. The new secret is written with
340
+ both fields.
341
+ - **The `none` rows** present a credential the broker cannot obtain again, so
342
+ a mismatch is refused, not discarded: a `DestinationConfigError` naming
343
+ `issuedFor` when the stored resource differs or is absent (or the means
344
+ state no `serviceUrl`), and naming `issuedBy` when the means state an issuer
345
+ (`oidcIssuerUrl` or the client for `jwt`, `samlAcsUrl` for `saml` — then
346
+ canonicalised as origin and path) and the stored one differs or is absent.
347
+ Means that state no issuer leave `issuedBy` uncompared.
348
+ - **The binding does not check the token.** Its audience stays the
349
+ resource's to enforce; the broker compares two strings and parses no token.
350
+ - **`bindingOf(means, client?)` — for a consumer that hands over a
351
+ credential.** A `none` destination presents a token or cookies the broker
352
+ cannot obtain; whoever writes them to the session store must write the
353
+ binding beside them. `bindingOf` answers exactly what the broker computes for
354
+ those means — the one function `getProvider` checks against and `persist`
355
+ writes (`issuedFor` from `serviceUrl` + `sapClient`, `issuedBy` from the
356
+ issuer, client or ACS the grant uses) — so a consumer never canonicalises a
357
+ URI itself:
358
+
359
+ ```typescript
360
+ const means = await keyStore.getConnectionConfig('DEV'); // saml / none
361
+ await sessionStore.saveSession('DEV', {
362
+ sessionCookies: cookies,
363
+ ...bindingOf(means ?? {}), // { issuedFor: 'https://dev.example.com:443?sap-client=100' }
364
+ });
365
+ ```
366
+
367
+ A destination that states no `jwt` / `saml` type or no grant binds nothing
368
+ (`{}`). `mcp-sso … --cookie` writes its cookies this way.
369
+
370
+ **What you meet:**
371
+
372
+ 1. **A custom `ISessionStore`** (a database, a message log, a secret store)
373
+ must persist `issuedFor` and `issuedBy` beside the secret, and answer
374
+ them from `loadSession`. One that drops them still type-checks, but the
375
+ broker then never reuses its sessions: every process start is a fresh
376
+ login — a browser each time for an interactive grant.
377
+ 2. **A headless process whose strategy refuses logins** gets Oops ("login
378
+ required") from `prepare()` / `rejected()` on a mismatch, instead of
379
+ presenting a foreign secret; log in again with the CLI.
380
+ 3. **Changing a destination's URL, SAP client, UAA or client** costs one
381
+ fresh login — intended.
382
+ 4. **Session files written before auth-stores 3.1.0** keep working: its
383
+ session stores answer `issuedFor` from the file's `SAP_URL` (+
384
+ `SAP_CLIENT`) and `issuedBy` from `SAP_UAA_URL` + `SAP_UAA_CLIENT_ID`,
385
+ which the 3.x broker and CLI wrote with the token — so a stored token whose
386
+ URL and client are the destination's is reused. XSUAA sessions written with
387
+ an empty URL have no `issuedFor`, and log in once.
388
+
389
+ **One provider per destination** for the broker's life, shared with the token
390
+ API when the broker has no `provider` option (see *Getting Tokens*):
391
+ concurrent first calls share one build, and a build that threw is tried again
392
+ on the next call. A destination rewritten from outside is picked up by a new
393
+ broker.
394
+
395
+ ### Persistence and `flush()`
396
+
397
+ Every token provider `getProvider` builds writes what it obtains back to the
398
+ session store, through its `onTokens` hook, before it answers — whichever
399
+ moment triggered the renewal: `prepare()`, `authorize()` on expiry, or
400
+ `rejected()` after a 401. A renewal inside a connector is stored before the
401
+ connector resends.
402
+
403
+ - **The secret alone, with its binding, in one write**:
404
+ `saveSession(destination, { authorizationToken, expiresAt, refreshToken,
405
+ issuedFor, issuedBy })` — or, for `saml2_pure`'s cookies (a result of
406
+ `tokenType: 'saml'`), `{ sessionCookies, expiresAt, issuedFor, issuedBy }`,
407
+ with no refresh token: SAML has none. `saml2_bearer`'s result is a token
408
+ and is written as one — `issuedFor` / `issuedBy` as computed when the
409
+ provider was built, each left out when the means lack its source (so the
410
+ store clears it). No means is ever written — not `serviceUrl`, not
411
+ `authType`, not the client: the client secret lives in the key store, and
412
+ the broker never writes the key store (its contract has no write method).
413
+ `expiresAt` is the result's, else the `expires_in` it reported counted from
414
+ when it arrived. A result without a refresh token keeps the one the session
415
+ holds — when that one is bound where the new secret is; one bound to
416
+ another resource or issuer is not carried into it.
417
+ - **`basic` and `snc` are never written**: they obtain no session secret, and
418
+ `none` obtains nothing either. A destination the key store states as
419
+ `basic` or `snc` at write time is not written.
420
+ - **The token API writes through the same path** (see *Getting Tokens*):
421
+ with a `provider` of yours, every answer it gets is written as above — the
422
+ stored refresh token carried forward whatever its binding, as your provider
423
+ was seeded with it.
424
+ - **A failed write does not fail the authentication.** The connector goes on
425
+ with the token it holds; the broker keeps the result pending for that
426
+ destination and retries on its own — one second, doubling, capped at one
427
+ minute — on a timer that never keeps the process alive. A newer result
428
+ replaces the pending one; writes for one destination never overlap. Each
429
+ failure is logged by its class name, never its message. The token API's
430
+ caller still gets the failure: `getToken()` / `refreshToken()` throw the
431
+ store's error for the token they received, while the retry goes on.
432
+ - **`flush()`** gives every pending write one more attempt, resolves when all
433
+ have landed, and rejects (an `AggregateError` naming the destinations; each
434
+ of its `errors` is `"<destination>": <error class>` — never the store's own
435
+ message, which may quote what was being written) when the store still
436
+ refuses — the broker keeps retrying.
437
+ Call it on shutdown — on `SIGTERM`, before a stdio transport closes — to
438
+ know whether every token is stored.
439
+
440
+ ```typescript
441
+ process.on('SIGTERM', async () => {
442
+ try {
443
+ await broker.flush();
444
+ } catch (error) {
445
+ logger.error(`Tokens not stored: ${(error as Error).message}`);
446
+ }
447
+ process.exit(0);
448
+ });
449
+ ```
450
+
451
+ **A destination that lacks what its type needs** is a `DestinationConfigError`
452
+ (see *Error Handling*), thrown by `getProvider` before any provider exists —
453
+ never a provider built only to refuse in `prepare()`.
96
454
 
97
455
  ### Headless Processes (No Browser)
98
456
 
@@ -125,6 +483,34 @@ try {
125
483
  A cached token that is still valid, or a refresh token the UAA accepts, never
126
484
  reaches the strategy.
127
485
 
486
+ With `getProvider` the same holds for every collaborator option: give the
487
+ broker an `authorization` / `oidcAuthorization` that refuses, and a
488
+ `deviceCodePresenter` that routes the code to wherever a person can see it —
489
+ or refuses. The provider then runs on its stored secret and refresh token;
490
+ when a login is needed, `prepare()` / `rejected()` answer Oops — whose
491
+ refusal carries fixed wording, never the message of what your collaborator
492
+ threw — and nothing is written:
493
+
494
+ ```typescript
495
+ const refuseLogin = {
496
+ authorize: async () => {
497
+ throw new LoginRequiredError('Run mcp-auth to log in');
498
+ },
499
+ };
500
+
501
+ const broker = new AuthBroker({
502
+ serviceKeyStore: myKeyStore,
503
+ sessionStore: mySessionStore,
504
+ authorization: () => refuseLogin,
505
+ oidcAuthorization: () => refuseLogin,
506
+ deviceCodePresenter: () => ({
507
+ present: async () => {
508
+ throw new LoginRequiredError('No one to show a device code to');
509
+ },
510
+ }),
511
+ });
512
+ ```
513
+
128
514
  ### Custom Browser Auth Port
129
515
 
130
516
  How a login is conducted — including which port the local OAuth2 callback
@@ -152,41 +538,118 @@ const token = await broker.getToken('TRIAL');
152
538
  const newToken = await broker.refreshToken('TRIAL');
153
539
  ```
154
540
 
155
- Both write the result to the session store: a SAML result as session cookies,
156
- anything else as the bearer token, and the refresh token when the result has
157
- one.
541
+ **Which provider answers.**
542
+
543
+ - **No `provider` option** — the destination's own: the very provider
544
+ `getProvider('TRIAL')` hands out, from the same per-destination cache. A
545
+ connector and the token API in one process therefore share one token, one
546
+ refresh token and one renewal: a `refreshToken()` while the connector renews
547
+ in `rejected()` joins that renewal, and a token the connector renewed is
548
+ what `getToken()` answers next. The destination must state a grant that
549
+ obtains a token — every grant but `none` (see the table under *A Provider
550
+ for a Connector*); a `none` destination is a `DestinationConfigError`
551
+ naming `provider`, before any provider is built.
552
+ - **A `provider` option** — yours, as in 3.x (see *Basic Usage*): the factory
553
+ is called once per destination (concurrent first calls build once; a call
554
+ after one that threw builds again), an instance serves every destination.
555
+
556
+ **What is written.** The session secret alone, in one `saveSession` — the
557
+ token, or the cookies of a SAML result (`tokenType: 'saml'`), its
558
+ `expiresAt`, the refresh token (the stored one carried forward when the
559
+ result has none), and what the secret is bound to (`issuedFor`, `issuedBy`,
560
+ see *A Secret Is Bound to Its Resource and Issuer*). Never `serviceUrl`,
561
+ `authType` or a client: they are means, and live in the key store.
562
+
563
+ - Without a `provider`, the token API writes nothing itself: the provider's
564
+ `onTokens` writes every token it obtains (see *Persistence and `flush()`*),
565
+ and a cache hit writes nothing.
566
+ - With a `provider`, the token API writes after every answer, cache hits
567
+ included, through the same write path. `issuedFor` is the `serviceUrl` it
568
+ resolved, with the SAP client; `issuedBy` the client a factory was handed —
569
+ an instance is handed none, so what it obtains is bound to no issuer and
570
+ `getProvider` never seeds from it. Both are fixed when your provider is
571
+ built for the destination (an instance: at its first call for it) and kept
572
+ with it: a URL or client changed later never re-labels a token that
573
+ provider obtained — a new broker picks the change up.
574
+
575
+ **A write that fails** reaches the caller: `getToken()` / `refreshToken()`
576
+ throw the store's error, as raised, for the token they received — and the
577
+ broker keeps retrying that write on its own; `flush()` tells you when it
578
+ landed.
579
+
580
+ **A destination stated `basic` or `snc` has no token API.** The token API
581
+ reads the destination's `authType` from the key store first and, for `basic`
582
+ or `snc`, throws `DestinationConfigError` naming `authType` before any provider
583
+ is asked — a token written over such a session would replace a credential
584
+ that is not one. A key store that states no `authType` (a 3.x setup), or no
585
+ key store, is served as before.
586
+
587
+ **Two token sources, with a `provider` option.** `getProvider` never uses
588
+ the `provider` option: the destination states its own provider. A process
589
+ that gives the broker a `provider` and also calls `getProvider` for the same
590
+ destination has two token sources for it — each with its own token and
591
+ renewal, both writing to the same session. Use one of them per destination;
592
+ or, to hand your own provider to a connector, wrap the token API:
593
+ `TokenAuthProvider.from(broker.createTokenRefresher('TRIAL'))`
594
+ (`@mcp-abap-adt/auth-providers`), whose every renewal the token API writes.
158
595
 
159
596
  ### Creating Token Refresher for DI
160
597
 
161
- The `createTokenRefresher()` method creates an `ITokenRefresher` implementation that can be injected into connections. This enables connections to handle token refresh transparently without knowing about authentication internals.
598
+ `createTokenRefresher(destination)` returns an `ITokenRefresher` (from
599
+ `@mcp-abap-adt/interfaces-auth`) bound to one destination — `getToken()` is
600
+ `broker.getToken(destination)`, `refreshToken()` is
601
+ `broker.refreshToken(destination)` — for a connection of your own that asks
602
+ for a token per request and for a new one after a 401. It is unchanged since
603
+ 3.x and not deprecated: calm-server injects it into its own connection.
162
604
 
163
605
  ```typescript
164
- import { AuthBroker } from '@mcp-abap-adt/auth-broker';
165
- import { JwtAbapConnection } from '@mcp-abap-adt/connection';
166
-
167
- // Create broker
168
- const broker = new AuthBroker({
169
- sessionStore: mySessionStore,
170
- serviceKeyStore: myServiceKeyStore,
171
- provider: myProviderFactory,
172
- });
173
-
174
- // Create token refresher for specific destination
175
606
  const tokenRefresher = broker.createTokenRefresher('TRIAL');
176
607
 
177
- // Inject into connection (connection can handle 401/403 automatically)
178
- const connection = new JwtAbapConnection(config, tokenRefresher);
179
-
180
- // Token refresher methods:
181
- // - getToken(): the provider's current token (broker.getToken)
182
- // - refreshToken(): a new token, never the cached one (broker.refreshToken)
608
+ // In your own connection:
609
+ const token = await tokenRefresher.getToken();
610
+ // …the server answered 401:
611
+ const newToken = await tokenRefresher.refreshToken();
183
612
  ```
184
613
 
185
- **Benefits of Token Refresher:**
186
- - 🔄 **Transparent Refresh**: Connection handles 401/403 errors automatically
187
- - 🧩 **Dependency Injection**: Clean separation of concerns
188
- - 💾 **Automatic Persistence**: Tokens saved to session store after refresh
189
- - 🎯 **Destination-Scoped**: Each refresher is bound to specific destination
614
+ A `@mcp-abap-adt/connection` 10 connector takes an `IAuthProvider` instead:
615
+ give it `await broker.getProvider('TRIAL')`.
616
+
617
+ ## Migrating from 3.x
618
+
619
+ 4.0.0 is a major: what a 3.x consumer must do, in short (the full list is the
620
+ [CHANGELOG](CHANGELOG.md)'s 4.0.0 entry).
621
+
622
+ 1. **Session store: `@mcp-abap-adt/auth-stores` 3 or later** (`^3.1.0` for
623
+ the binding), or one of your own on the
624
+ `@mcp-abap-adt/interfaces-auth-broker` contract. The broker writes the
625
+ secret alone — token or cookies, `expiresAt`, refresh token, `issuedFor`,
626
+ `issuedBy` — and auth-stores 1.x/2.x's session stores refuse such a write
627
+ (their `AbapSessionStore` throws without `serviceUrl`): they are not
628
+ supported. A custom store must keep `issuedFor` and `issuedBy`.
629
+ 2. **State the means in a key store**, not in the session: `authType`, and
630
+ for `jwt` / `saml` a `grantType`; the client; the URL. For a SAP service
631
+ key, `new AbapServiceKeyStore(dir, { grantType: 'authorization_code' })`
632
+ (or `XsuaaServiceKeyStore`); otherwise `EnvDestinationStore(dir, {
633
+ fallback })`. A 3.x session file is a readable destination as it is once
634
+ it states `SAP_AUTH_TYPE` (and `SAP_GRANT_TYPE` for `jwt` / `saml`).
635
+ 3. **A connector of `@mcp-abap-adt/connection` 10** takes `await
636
+ broker.getProvider(destination)` — no `getToken` before connecting, no
637
+ token refresher. Give the broker the collaborators its grants need
638
+ (`authorization`, `oidcAuthorization`, `deviceCodePresenter`,
639
+ `samlCookies`, `assertionReplayStore`); there is no default.
640
+ 4. **The token API** keeps its calls, signatures and 3.x reads with a
641
+ `provider` of yours; it writes the secret alone, refuses a destination
642
+ stated `basic` or `snc`, and throws a failed write while the broker retries
643
+ it.
644
+ 5. **`getConnectionConfig` / `getAuthorizationConfig`** compose the key
645
+ store's means with the session's secret; a URL or client kept only in the
646
+ session store is not found any more.
647
+ 6. **New:** catch `DestinationConfigError`; call `flush()` on shutdown;
648
+ write `bindingOf(means)` beside a credential you hand over yourself.
649
+ 7. **Contracts:** `interfaces-auth` 3, `interfaces-auth-sap` 2,
650
+ `interfaces-auth-broker` 1.1 (the store contracts moved there from
651
+ `interfaces-auth-sap`), auth-providers 5. The commands are
652
+ `@mcp-abap-adt/auth-broker-cli` 2.0.0.
190
653
 
191
654
  ## Migrating from 2.2.0
192
655
 
@@ -208,117 +671,78 @@ const connection = new JwtAbapConnection(config, tokenRefresher);
208
671
  it from the service key store if you relied on finding it in the session.
209
672
  6. `refreshToken()` now forces a new token; it used to return `getToken()`'s.
210
673
  7. Node.js 22, 24 or 26; SAML runs need the IdP trust (see *Migrating `mcp-sso`
211
- SAML runs from 2.2.0*).
674
+ SAML runs from 2.2.0* in the [CLI's README](../auth-broker-cli/README.md)).
212
675
 
213
676
  ## Configuration
214
677
 
215
678
  ### Environment Variables
216
679
 
217
- #### Configuration Variables
218
-
219
- - `AUTH_BROKER_PATH` - Colon/semicolon-separated paths for searching `.env` and `.json` files (default: current working directory)
220
-
221
- #### Debugging Variables
222
-
223
- - `DEBUG_BROKER` - Enable debug logging for `auth-broker` package (short name)
224
- - Set to `true` to enable logging (default: `false`)
225
- - When enabled, logs authentication steps, token operations, and error details
226
- - Can be explicitly disabled by setting to `false`
227
- - Example: `DEBUG_BROKER=true npm test`
228
-
229
- - `DEBUG_AUTH_BROKER` - Long name (backward compatibility)
230
- - Same as `DEBUG_BROKER`, but longer name
231
- - Example: `DEBUG_AUTH_BROKER=true npm test`
232
-
233
- - `LOG_LEVEL` - Control log verbosity level
234
- - Values: `debug`, `info`, `warn`, `error` (default: `info`)
235
- - `debug` - All messages including detailed debug information
236
- - `info` - Informational messages, warnings, and errors
237
- - `warn` - Warnings and errors only
238
- - `error` - Errors only
239
- - Example: `LOG_LEVEL=debug DEBUG_BROKER=true npm test`
240
-
241
- - `DEBUG` - Alternative way to enable debugging
242
- - Set to `true` to enable all debug logging
243
- - Or set to a string containing `broker` or `auth-broker` to enable only this package
244
- - Example: `DEBUG=true npm test` or `DEBUG=broker npm test` or `DEBUG=auth-broker npm test`
245
-
246
- **Note**: For debugging related packages:
247
- - `DEBUG_STORES` (short) or `DEBUG_AUTH_STORES` (long) - Enable logging for `@mcp-abap-adt/auth-stores` package
248
- - `DEBUG_PROVIDER` (short) or `DEBUG_AUTH_PROVIDERS` (long) - Enable logging for `@mcp-abap-adt/auth-providers` package
680
+ The library reads no environment variable: the stores take their directories
681
+ from their constructors, and the providers their settings from the
682
+ destination's means and the collaborators you give the broker. (In the
683
+ repository's test suites, `DEBUG_BROKER=true` — or `DEBUG_AUTH_BROKER=true`,
684
+ `DEBUG=broker` — turns on the test logger, its level from `AUTH_LOG_LEVEL`.)
249
685
 
250
- **Legacy Support**: `DEBUG_AUTH_LOG` is still supported for backward compatibility (equivalent to `DEBUG_BROKER=true LOG_LEVEL=debug`)
686
+ ### Logging
251
687
 
252
- ### Logging Features
688
+ Pass an `ILogger` as the constructor's second argument (for instance
689
+ `DefaultLogger` from `@mcp-abap-adt/logger`); without one, nothing is logged.
253
690
 
254
- When logging is enabled (via `DEBUG_BROKER=true` or `DEBUG_AUTH_BROKER=true`), the broker provides detailed structured logging:
691
+ **What is logged:** the broker's initialization (whether a key store is
692
+ given; the `provider` option: `none`, `factory` or `instance`), each provider build (the destination's `authType` and grant, whether
693
+ it was seeded), each session secret saved (token or cookies, whether a
694
+ refresh token came back, the expiry), a stored secret discarded because it is
695
+ bound elsewhere (the destination only), and each failed write with the error's
696
+ class name.
255
697
 
256
- **What is logged:** broker initialization (which stores, instance or factory),
257
- provider builds (whether credentials, a refresh token and a stored token were
258
- there), and each token saved (token type, grant, whether a refresh token came
259
- back, expiry). Store read failures are logged as warnings.
698
+ **What is never logged:** any part of a token, refresh token, password or
699
+ secret — not a prefix, not a suffix — nor a store error's message, which may
700
+ quote what was being written.
260
701
 
261
- **What is never logged:** any part of a token, refresh token or secret — not a
262
- prefix, not a suffix. A log line says whether a token is there, never what it is.
702
+ With `DefaultLogger` at `info`:
263
703
 
264
- Example output with `DEBUG_BROKER=true LOG_LEVEL=info`:
265
704
  ```
266
- [INFO] ℹ️ [AUTH-BROKER] [AuthBroker] Token saved for TRIAL: tokenType(jwt), authType(authorization_code), hasRefreshToken(true), expiresAt(2026-09-26T20:15:30.000Z)
705
+ [INFO] ℹ️ [AuthBroker] Session secret saved for TRIAL
706
+ {"credential":"token","hasRefreshToken":true,"expiresAt":1790000000000}
267
707
  ```
268
708
 
269
- **Note**: Logging only works when a logger is explicitly provided to the broker constructor. The broker will not output anything to console if no logger is passed.
270
-
271
709
  ### File Structure
272
710
 
273
- #### Environment File for ABAP (`{destination}.env`)
274
-
275
- For ABAP connections, use `SAP_*` environment variables:
711
+ With `@mcp-abap-adt/auth-stores` 3 one `<destination>.env` file can hold a
712
+ destination whole, each store reading and writing only its own keys — the
713
+ means through `EnvDestinationStore`, the secret through `AbapSessionStore`
714
+ (or both in separate directories, as you compose them):
276
715
 
277
716
  ```env
717
+ # The means — EnvDestinationStore (ABAP_DESTINATION_VARS); the broker never writes them
278
718
  SAP_URL=https://your-system.abap.us10.hana.ondemand.com
279
719
  SAP_CLIENT=100
280
- SAP_JWT_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
281
- SAP_REFRESH_TOKEN=refresh_token_string
720
+ SAP_AUTH_TYPE=jwt
721
+ SAP_GRANT_TYPE=authorization_code
282
722
  SAP_UAA_URL=https://your-account.authentication.us10.hana.ondemand.com
283
723
  SAP_UAA_CLIENT_ID=client_id
284
724
  SAP_UAA_CLIENT_SECRET=client_secret
285
- ```
286
725
 
287
- #### Environment File for XSUAA (`{destination}.env`)
288
-
289
- For XSUAA connections (reduced scope), use `XSUAA_*` environment variables:
290
-
291
- ```env
292
- XSUAA_MCP_URL=https://your-mcp-server.cfapps.eu10.hana.ondemand.com
293
- XSUAA_JWT_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
294
- XSUAA_REFRESH_TOKEN=refresh_token_string
295
- XSUAA_UAA_URL=https://your-account.authentication.eu10.hana.ondemand.com
296
- XSUAA_UAA_CLIENT_ID=client_id
297
- XSUAA_UAA_CLIENT_SECRET=client_secret
298
- ```
299
-
300
- **Note**: `XSUAA_MCP_URL` is optional - it's not part of authentication, only needed for making requests. The token and UAA credentials are sufficient for authentication.
301
-
302
- #### Environment File for BTP (`{destination}.env`)
303
-
304
- For BTP connections (full scope for ABAP systems), use `BTP_*` environment variables:
305
-
306
- ```env
307
- BTP_ABAP_URL=https://your-system.abap.us10.hana.ondemand.com
308
- BTP_JWT_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
309
- BTP_REFRESH_TOKEN=refresh_token_string
310
- BTP_UAA_URL=https://your-account.authentication.eu10.hana.ondemand.com
311
- BTP_UAA_CLIENT_ID=client_id
312
- BTP_UAA_CLIENT_SECRET=client_secret
313
- BTP_SAP_CLIENT=100
314
- BTP_LANGUAGE=EN
726
+ # The secret — AbapSessionStore (ABAP_SESSION_VARS); what the broker writes
727
+ SAP_JWT_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
728
+ SAP_EXPIRES_AT=1790000000000
729
+ SAP_REFRESH_TOKEN=refresh_token_string
730
+ SAP_ISSUED_FOR=https://your-system.abap.us10.hana.ondemand.com:443?sap-client=100
731
+ SAP_ISSUED_BY=https://your-account.authentication.us10.hana.ondemand.com:443?client_id=client_id
315
732
  ```
316
733
 
317
- **Note**: `BTP_ABAP_URL` is required - it's the ABAP system URL. All parameters (except tokens) come from service key.
734
+ SAML cookies are `SAP_SESSION_COOKIES_B64` in place of `SAP_JWT_TOKEN`. The
735
+ XSUAA stores use the same names with `XSUAA_` (`XSUAA_DESTINATION_VARS`,
736
+ `XSUAA_SESSION_VARS`; the URL is `XSUAA_MCP_URL`). Every means key
737
+ (`SAP_USERNAME`, `SAP_SNC_*`, `SAP_OIDC_*`, `SAP_SAML_*`, …) is listed in
738
+ [USAGE.md](../../docs/using/USAGE.md#environment-variables).
318
739
 
319
- #### Service Key File for ABAP (`{destination}.json`)
740
+ #### Service Key File (`{destination}.json`)
320
741
 
321
- Standard ABAP service key format:
742
+ A SAP service key, read by `AbapServiceKeyStore` (an ABAP environment key:
743
+ the client and the ABAP URL) or `XsuaaServiceKeyStore` (an XSUAA key: the
744
+ client). A key cannot state which grant the destination uses, so the store is
745
+ told: `new AbapServiceKeyStore(dir, { grantType: 'authorization_code' })`.
322
746
 
323
747
  ```json
324
748
  {
@@ -331,40 +755,9 @@ Standard ABAP service key format:
331
755
  }
332
756
  ```
333
757
 
334
- #### Service Key File for XSUAA (`{destination}.json`)
335
-
336
- Direct XSUAA service key format (from BTP):
337
-
338
- ```json
339
- {
340
- "url": "https://your-account.authentication.eu10.hana.ondemand.com",
341
- "apiurl": "https://api.authentication.eu10.hana.ondemand.com",
342
- "clientid": "your_client_id",
343
- "clientsecret": "your_client_secret"
344
- }
345
- ```
346
-
347
- **Note**: For XSUAA service keys, `apiurl` is prioritized over `url` for UAA authorization if present.
348
-
349
- ## XSUAA vs BTP Authentication
350
-
351
- This package supports two types of BTP authentication:
352
-
353
- ### XSUAA (Reduced Scope)
354
- - **Purpose**: Access BTP services with limited scopes
355
- - **Service Key**: Contains only UAA credentials (no ABAP URL)
356
- - **Session Store**: `XsuaaSessionStore` (uses `XSUAA_*` environment variables)
357
- - **Authentication**: Client credentials grant type (no browser required)
358
- - **MCP URL**: Optional, provided separately (from YAML config `mcp_url`, parameter, or request header)
359
- - **Use Case**: Accessing BTP services like MCP servers with reduced permissions
360
-
361
- ### BTP (Full Scope for ABAP)
362
- - **Purpose**: Access ABAP systems with full roles and scopes
363
- - **Service Key**: Contains UAA credentials and ABAP URL
364
- - **Session Store**: `BtpSessionStore` (uses `BTP_*` environment variables)
365
- - **Authentication**: Browser-based OAuth2 (like ABAP) or refresh token
366
- - **ABAP URL**: Required, from service key or YAML configuration
367
- - **Use Case**: Accessing ABAP systems in BTP with full permissions
758
+ An XSUAA key carries no URL of the resource it authorizes for: state it as
759
+ means in an `EnvDestinationStore` (`XSUAA_MCP_URL`, with
760
+ `XSUAA_DESTINATION_VARS`) whose `fallback` is the `XsuaaServiceKeyStore`.
368
761
 
369
762
  ## Responsibilities and Design Principles
370
763
 
@@ -373,7 +766,7 @@ This package supports two types of BTP authentication:
373
766
  **Interface-Only Communication**: This package follows a fundamental development principle: **all interactions with external dependencies happen ONLY through interfaces**. The code knows **NOTHING beyond what is defined in the interfaces**.
374
767
 
375
768
  This means:
376
- - Does not know about concrete implementation classes (e.g., `AbapSessionStore`, `AuthorizationCodeProvider`)
769
+ - Does not know about concrete store classes (e.g., `AbapSessionStore`); the provider classes it knows only to construct the one a destination states, and hands out as `IAuthProvider`
377
770
  - Does not know about internal data structures or methods not defined in interfaces
378
771
  - Does not make assumptions about implementation behavior beyond interface contracts
379
772
  - Does not access properties or methods not explicitly defined in interfaces
@@ -390,11 +783,11 @@ The `@mcp-abap-adt/auth-broker` package defines **interfaces** and provides **or
390
783
 
391
784
  #### What AuthBroker Does
392
785
 
393
- - **Resolves what the stores hold**: the service URL, the UAA credentials, the stored token and refresh token
394
- - **Builds or reuses the provider**: a factory is called once per destination, seeded with the above
786
+ - **Resolves what the stores hold**: the means from the key store, the secret from the session store (and, for a `provider` of yours, the 3.x reads: the service URL and the UAA credentials, session first)
787
+ - **Builds or reuses the provider**: one per destination — the one the destination states, shared by `getProvider` and the token API; or your factory, called once per destination
395
788
  - **Asks the provider once**: `getTokens()` for `getToken()`, `refreshTokens()` for `refreshToken()` — no retries, no fallbacks
396
- - **Persists the answer**: token or session cookies, and the refresh token, to `sessionStore`
397
- - **Works with interfaces only**: `IServiceKeyStore`, `ISessionStore`, `IRefreshableTokenProvider`
789
+ - **Persists the answer**: the session secret alone — token or session cookies, `expiresAt`, the refresh token, and what it is bound to — to `sessionStore`, retrying a write that fails
790
+ - **Works with interfaces only**: `IServiceKeyStore`, `ISessionStore`, `IAuthProvider`, `IRefreshableTokenProvider`
398
791
 
399
792
  #### What AuthBroker Does NOT Do
400
793
 
@@ -408,19 +801,15 @@ The `@mcp-abap-adt/auth-broker` package defines **interfaces** and provides **or
408
801
 
409
802
  The **consumer** (application using `AuthBroker`) is responsible for:
410
803
 
411
- 1. **Selecting appropriate implementations**: Choose the correct `IServiceKeyStore`, `ISessionStore`, and `IRefreshableTokenProvider` implementations based on the use case:
412
- - **ABAP systems**: Use `AbapServiceKeyStore`, `AbapSessionStore` (or `SafeAbapSessionStore`), and `AuthorizationCodeProvider`
413
- - **BTP systems**: Use `AbapServiceKeyStore`, `BtpSessionStore` (or `SafeBtpSessionStore`), and `AuthorizationCodeProvider`
414
- - **XSUAA services**: Use `XsuaaServiceKeyStore`, `XsuaaSessionStore` (or `SafeXsuaaSessionStore`), and `ClientCredentialsProvider`
804
+ 1. **Composing the stores and collaborators**: an `IServiceKeyStore` for the means and an `ISessionStore` for the secret — with auth-stores 3:
805
+ - **ABAP systems (a SAP service key)**: `AbapServiceKeyStore(dir, { grantType })`, alone or as the `fallback` of an `EnvDestinationStore`, and `AbapSessionStore` (or `SafeAbapSessionStore`, in memory)
806
+ - **Destinations without a key** (basic, SNC, OIDC, SAML, `none`): `EnvDestinationStore(dir)` and `AbapSessionStore`
807
+ - **XSUAA services**: `EnvDestinationStore(dir, { variables: XSUAA_DESTINATION_VARS, fallback: new XsuaaServiceKeyStore(keys, { grantType }) })` and `XsuaaSessionStore` (or `SafeXsuaaSessionStore`)
808
+ - the collaborator options the destinations' grants need — or, for the token API the 3.x way, an `IRefreshableTokenProvider` of your own
415
809
 
416
- 2. **Ensuring complete configuration**: If a session store requires `serviceUrl` (e.g., `AbapSessionStore` requires `sapUrl`), the consumer must ensure that:
417
- - The session is created with `serviceUrl` before calling `AuthBroker.getToken()`, OR
418
- - The session store implementation handles `serviceUrl` retrieval internally (e.g., from `serviceKeyStore`)
810
+ 2. **Stating the means in the key store**: `authType`, `grantType` and the fields its row reads, the client, and `serviceUrl` — for the token API with a `provider` of yours, the service URL comes from the session, else the key store.
419
811
 
420
- 3. **Understanding store requirements**: Different session store implementations have different requirements:
421
- - `AbapSessionStore`: Requires `sapUrl` (maps to `serviceUrl` in `IConnectionConfig`)
422
- - `BtpSessionStore`: Does not require `serviceUrl` (uses `mcpUrl` instead)
423
- - `XsuaaSessionStore`: Does not require `serviceUrl` (MCP URL is optional)
812
+ 3. **A session store that takes the secret alone**: the broker writes the token or cookies, `expiresAt`, the refresh token, `issuedFor` and `issuedBy` in one `saveSession`, and nothing else. auth-stores 3's session stores do; auth-stores 1.x/2.x's `AbapSessionStore` and `SafeAbapSessionStore` refuse a write without `serviceUrl` (measured on 1.2.3, 1.2.4, 2.0.0), so a token API on them throws after every token. A custom store must also keep `issuedFor` and `issuedBy`.
424
813
 
425
814
  ### Store Responsibilities
426
815
 
@@ -428,10 +817,7 @@ Concrete `ISessionStore` implementations are responsible for:
428
817
 
429
818
  - **Handling their own data format**: Each store knows its internal data format (e.g., `AbapSessionData`, `BtpBaseSessionData`)
430
819
  - **Converting between formats**: Converting between `IConfig`/`IConnectionConfig` and internal storage format
431
- - **Managing required fields**: If a store requires `serviceUrl` (e.g., `AbapSessionStore`), it should:
432
- - Retrieve it from `serviceKeyStore` if not provided in `IConnectionConfig`, OR
433
- - Use existing value from current session if available, OR
434
- - Throw an error if neither is available (depending on implementation)
820
+ - **Taking the secret alone**: a session store is written the secret and its binding only, and keeps what it held beside them (auth-stores 3 refuses means outright)
435
821
 
436
822
  ### Provider Responsibilities
437
823
 
@@ -445,7 +831,7 @@ Concrete `IRefreshableTokenProvider` implementations are responsible for:
445
831
  ### Design Principles
446
832
 
447
833
  1. **Interface-Only Communication** (Core Principle): All interactions with external dependencies happen **ONLY through interfaces**. The code knows **NOTHING beyond what is defined in the interfaces** (see [Core Development Principle](#core-development-principle) above)
448
- 2. **Dependency Inversion Principle (DIP)**: `AuthBroker` depends on abstractions (`IServiceKeyStore`, `ISessionStore`, `IRefreshableTokenProvider`), not concrete implementations
834
+ 2. **Dependency Inversion Principle (DIP)**: `AuthBroker` depends on abstractions (`IServiceKeyStore`, `ISessionStore`, `IAuthProvider`, `IRefreshableTokenProvider`), not concrete store implementations; the providers it builds it hands out as `IAuthProvider`
449
835
  3. **Single Responsibility**: Each component has a single, well-defined responsibility:
450
836
  - `AuthBroker`: Orchestration — resolving, asking, persisting
451
837
  - `ISessionStore`: Session data storage and retrieval
@@ -464,54 +850,134 @@ Concrete `IRefreshableTokenProvider` implementations are responsible for:
464
850
  new AuthBroker(
465
851
  config: {
466
852
  sessionStore: ISessionStore; // required
467
- serviceKeyStore?: IServiceKeyStore; // optional
468
- provider: // required
853
+ serviceKeyStore?: IServiceKeyStore; // getProvider needs it
854
+ provider?: // the token API's, optional
469
855
  | IRefreshableTokenProvider
470
856
  | ((
471
857
  destination: string,
472
858
  authConfig: IAuthorizationConfig | null,
473
859
  connConfig: IConnectionConfig,
474
860
  ) => IRefreshableTokenProvider);
861
+ // Collaborators, each a function of the destination, called once per
862
+ // build, never disposed by the broker:
863
+ authorization?: (destination: string, grant: StrategyGrant) => IAuthorizationStrategy<string>; // authorization_code, passcode, saml2_pure, saml2_bearer
864
+ oidcAuthorization?: (destination: string) => IAuthorizationStrategy<OidcCallbackResult>; // oidc_authorization_code
865
+ deviceCodePresenter?: (destination: string) => IDeviceCodePresenter; // device_code
866
+ samlCookies?: (destination: string) => (samlResponse: string) => Promise<string>; // saml2_pure
867
+ assertionReplayStore?: (destination: string) => IAssertionReplayStore; // saml2_pure, saml2_bearer
475
868
  },
476
869
  logger?: ILogger,
477
870
  )
478
871
  ```
479
872
 
480
873
  **Parameters:**
481
- - `config.sessionStore` - **Required** - Where tokens and the refresh token are kept. Its `serviceUrl`, or the service key's, is required.
482
- - `config.serviceKeyStore` - **Optional** - UAA credentials and the service URL.
483
- - `config.provider` - **Required** - A provider instance, used for every destination, or a factory (`TokenProviderFactory`), called once per destination and seeded with what the stores hold (see *Basic Usage*).
874
+ - `config.sessionStore` - **Required** - The session secret: the token or cookies, `expiresAt`, the refresh token, and what it is bound to (`issuedFor`, `issuedBy`). For the token API with a `provider`, its `serviceUrl`, or the service key's, is required (3.x's reads).
875
+ - `config.serviceKeyStore` - The means: `authType`, `grantType`, the client, basic's user and password, the SNC, OIDC and SAML fields, `serviceUrl`. **Required by `getProvider`**, which has no other source of means.
876
+ - `config.provider` - The token API's source (`getToken`, `refreshToken`, `createTokenRefresher`): a provider instance, used for every destination, or a factory (`TokenProviderFactory`), called once per destination and seeded with what the stores hold (see *Basic Usage*). Not used by `getProvider`. Without it, the token API asks the provider `getProvider` builds for the destination (see *Getting Tokens*); with neither it nor a `serviceKeyStore`, the token API throws `DestinationConfigError` naming both.
877
+ - `config.authorization` - The interactive strategy of `jwt` / `authorization_code`, `jwt` / `passcode`, `saml` / `saml2_pure` and `saml` / `saml2_bearer`, as a function of the destination and the grant (see *A Provider for a Connector*).
878
+ - `config.oidcAuthorization` - The interactive strategy of `jwt` / `oidc_authorization_code`.
879
+ - `config.deviceCodePresenter` - Where `jwt` / `device_code` shows the user the verification URL and code.
880
+ - `config.samlCookies` - `saml2_pure`: turns the validated SAMLResponse into the system's session cookies.
881
+ - `config.assertionReplayStore` - The replay store the SAML validators record each assertion in.
882
+ - Each collaborator option is required only by the rows that use it (missing → `DestinationConfigError` naming it); the broker supplies no default, calls it once per build, and disposes nothing it returns.
484
883
  - `logger` - Optional logger. If not provided, nothing is logged.
485
884
 
486
885
  **Available Implementations:**
487
- - **ABAP**: `AbapServiceKeyStore(directory, logger?)`, `AbapSessionStore(directory, logger?, defaultServiceUrl?)`, `SafeAbapSessionStore(logger?, defaultServiceUrl?)`, `AuthorizationCodeProvider(...)`
488
- - **XSUAA** (reduced scope): `XsuaaServiceKeyStore(directory, logger?)`, `XsuaaSessionStore(directory, defaultServiceUrl, logger?)`, `SafeXsuaaSessionStore(defaultServiceUrl, logger?)`, `ClientCredentialsProvider(...)`
886
+ - **Means** (auth-stores 3): `EnvDestinationStore(directory, { fallback? })`, `AbapServiceKeyStore(directory, { grantType?, log? })`, `XsuaaServiceKeyStore(directory, { grantType?, log? })`
887
+ - **Secret** (auth-stores 3): `AbapSessionStore(directory, log?)`, `SafeAbapSessionStore(log?)`, `XsuaaSessionStore(directory, log?)`, `SafeXsuaaSessionStore(log?)`, `EnvFileSessionStore(file, log?)`
888
+ - **Providers** (auth-providers 5): `AuthorizationCodeProvider(...)`, `ClientCredentialsProvider(...)` and the rest, for a `provider` of yours
489
889
 
490
890
  #### Methods
491
891
 
892
+ ##### `getProvider(destination: string): Promise<IAuthProvider>`
893
+
894
+ The `IAuthProvider` the destination states, built from the key store's means
895
+ and the session store's secret (see *A Provider for a Connector*). Cached per
896
+ destination; a failed build is retried on the next call. Throws
897
+ `DestinationConfigError` for a destination that lacks what its type needs, and
898
+ passes a store failure other than absence on as the store raised it.
899
+
900
+ ##### `flush(): Promise<void>`
901
+
902
+ Every session write still pending gets one more attempt. Resolves when all
903
+ have landed; rejects with an `AggregateError` naming the destinations whose
904
+ store still refuses (its `errors` carry each destination and the store
905
+ error's class only, never its message) — the broker keeps retrying them. See *Persistence and `flush()`*.
906
+
492
907
  ##### `getToken(destination: string): Promise<string>`
493
908
 
494
- 1. Resolves the destination's `serviceUrl` (session, else service key; an error if neither has one).
495
- 2. Builds the provider on first use (factory form), seeded with the credentials, the stored refresh token and the stored token — or uses the instance.
496
- 3. Calls `provider.getTokens()` once. The provider answers from its cache, refreshes, or logs in.
497
- 4. Persists the result: `sessionCookies` for `tokenType: 'saml'`, else `authorizationToken`; the refresh token when the result has one.
498
- 5. Returns the token.
909
+ 1. Reads the destination's `authType` from the key store: `basic` or `snc` is a `DestinationConfigError` naming `authType`, before any provider is asked.
910
+ 2. **Without a `provider` option:** takes the provider `getProvider(destination)` hands out (the same cache; a `none` destination is a `DestinationConfigError` naming `provider`), calls `getTokens()` once, and writes nothing itself — the provider's `onTokens` wrote anything new.
911
+ 3. **With one:** resolves `serviceUrl` (session, else service key; an error if neither has one), builds the provider on first use (factory form) seeded with the credentials, the stored refresh token and the stored token — or uses the instance — calls `getTokens()` once, and writes the result: the secret alone, through the broker's write path.
912
+ 4. Throws the store's error if the write of this token failed (the broker keeps retrying it); else returns the token.
499
913
 
500
914
  ##### `refreshToken(destination: string): Promise<string>`
501
915
 
502
916
  The same, with `provider.refreshTokens()`: a new token, never the cached one —
503
- for a caller whose token the server has just refused.
917
+ for a caller whose token the server has just refused. A renewal already in
918
+ flight for the destination (a connector's, in `rejected()`) is joined.
504
919
 
505
920
  ##### `getAuthorizationConfig(destination)` / `getConnectionConfig(destination)`
506
921
 
507
- The session's configuration, else the service key's, else `null`.
922
+ The two stores composed, each for its role:
923
+
924
+ - `getConnectionConfig` — the service key store's means with the session's
925
+ secret (`authorizationToken`, `sessionCookies`, `expiresAt`) laid over them;
926
+ `null` when neither store holds anything.
927
+ - `getAuthorizationConfig` — the service key store's client (`uaaUrl`,
928
+ `uaaClientId`, `uaaClientSecret`) with the session's refresh token; `null`
929
+ when the key store has no client.
930
+
931
+ Means a session store answers (a client, a URL, an `authType`) are not read,
932
+ nor a secret a key store answers. Up to 3.1.0 both answered the session's
933
+ configuration whole, and the key's only when the session had none.
508
934
 
509
935
  ##### `createTokenRefresher(destination): ITokenRefresher`
510
936
 
511
- `getToken()` and `refreshToken()` bound to one destination, for injection into a connection.
937
+ `getToken()` and `refreshToken()` bound to one destination, for injection into a connection of your own. Unchanged since 3.x; not deprecated.
512
938
 
513
939
  ##### Error Handling
514
940
 
941
+ - **`DestinationConfigError`** — a destination that lacks what its type needs.
942
+ `getProvider` throws it before any provider is asked; so does the token
943
+ API, for the same destinations and for those below. It carries `code:
944
+ 'DESTINATION_CONFIG'`, `destination`, `missingFields` — field or option
945
+ names only. When a provider's constructor refused (an `sncQop` outside `1`,
946
+ `2`, `3`, `8`, `9`), the store field is named and the provider's own error
947
+ is not kept — its message quotes the value. **No stored value
948
+ reaches it**: not a password, not a token, not an `authType` that is none of
949
+ the four. Thrown for:
950
+
951
+ | Case | `missingFields` |
952
+ |---|---|
953
+ | no `serviceKeyStore` option (`getProvider`) | `serviceKeyStore` |
954
+ | the token API with neither a `provider` nor a `serviceKeyStore` option | `provider`, `serviceKeyStore` |
955
+ | the token API on a destination stated `basic` or `snc` | `authType` |
956
+ | the token API without a `provider` option, on a `jwt` / `none` or `saml` / `none` destination | `provider` |
957
+ | the key store has no means for the destination — whatever the session holds | `authType` |
958
+ | no `authType`, `''`, or one that is not `basic`, `jwt`, `saml`, `snc` | `authType` |
959
+ | `jwt` / `saml` without `grantType`, `''`, or a pair outside the table | `grantType` |
960
+ | `basic` without user or password (`''` counts as missing) | `username`, `password` — each that is missing |
961
+ | `snc` without `sncPartnerName` | `sncPartnerName` |
962
+ | `snc` whose settings the provider refuses | the store field, e.g. `sncQop` |
963
+ | `jwt` / `none` without a token in the session | `authorizationToken` |
964
+ | `saml` / `none` without cookies in the session | `sessionCookies` |
965
+ | `jwt` / `none`, `saml` / `none` whose stored `issuedFor` is not the destination's resource, or is absent, or the means state no `serviceUrl` | `issuedFor` |
966
+ | `jwt` / `none`, `saml` / `none` whose means state an issuer and whose stored `issuedBy` is not it, or is absent | `issuedBy` |
967
+ | a UAA grant without its client in the key store (`''` counts as missing) | `uaaUrl`, `uaaClientId`, and `uaaClientSecret` for `authorization_code` / `client_credentials` — each that is missing |
968
+ | `authorization_code` / `passcode` without the `authorization` option | `authorization` |
969
+ | an OIDC grant without its client's id in the key store | `uaaClientId` |
970
+ | an OIDC grant with neither `oidcIssuerUrl` nor every endpoint its row reads | `oidcIssuerUrl`, and each endpoint missing: `oidcAuthorizationEndpoint` / `oidcDeviceAuthorizationEndpoint`, `oidcTokenEndpoint` |
971
+ | `password` without user or password | `username`, `password` — each that is missing |
972
+ | `token_exchange` without its subject | `oidcSubjectToken`, `oidcSubjectTokenType` — each that is missing |
973
+ | `oidc_authorization_code` without the `oidcAuthorization` option | `oidcAuthorization` |
974
+ | `device_code` without the `deviceCodePresenter` option | `deviceCodePresenter` |
975
+ | a SAML grant without its trust (`''` and an empty certificate list count as missing) | `samlIdpSsoUrl`, `samlSpEntityId`, `samlIdpEntityId`, `samlIdpCertificates` — each that is missing |
976
+ | `saml2_bearer` without its client | `uaaUrl`, `uaaClientId` — each that is missing |
977
+ | a SAML grant without its collaborators | `authorization`, `samlCookies` (`saml2_pure`), `assertionReplayStore` — each that is missing |
978
+ | a certificate the validator cannot read | `samlIdpCertificates` |
979
+ | a `samlClockSkewMs` that is not a whole, non-negative number | `samlClockSkewMs` |
980
+
515
981
  - **Provider errors propagate unchanged** — the same object, with its class,
516
982
  `code`, `missingFields` and `cause`: auth-providers' `ValidationError`,
517
983
  `BrowserAuthError`, `AssertionValidationError`, network errors (`ECONNREFUSED`,
@@ -528,12 +994,25 @@ The session's configuration, else the service key's, else `null`.
528
994
  the new token was written. (The session stores of
529
995
  auth-stores answer an unreadable session file with `null` themselves, so it
530
996
  reads as absent before the broker sees it.)
531
- - **Store writes propagate**: a token that cannot be saved is an error.
997
+ - **Store writes**: a write that fails reaches the token API's caller — the
998
+ store's error, as raised, for the token it received — and is retried by the
999
+ broker all the same. A `getProvider` provider's write never fails the
1000
+ authentication; it is retried, and `flush()` reports it.
532
1001
  - **A provider result without a token** is an error.
533
1002
 
534
1003
  ```typescript
1004
+ import { DestinationConfigError } from '@mcp-abap-adt/auth-broker';
535
1005
  import { ValidationError } from '@mcp-abap-adt/auth-providers';
536
1006
 
1007
+ try {
1008
+ const provider = await broker.getProvider('TRIAL');
1009
+ } catch (error) {
1010
+ if (error instanceof DestinationConfigError) {
1011
+ logger.error(`${error.destination} lacks: ${error.missingFields.join(', ')}`);
1012
+ }
1013
+ throw error;
1014
+ }
1015
+
537
1016
  try {
538
1017
  const token = await broker.getToken('TRIAL');
539
1018
  } catch (error) {
@@ -546,38 +1025,32 @@ try {
546
1025
 
547
1026
  #### Secrets in the Session Store
548
1027
 
549
- The broker writes the token (or session cookies) and the refresh token to the
550
- session store — never the client secret. When the credentials come from the
551
- service key they stay there. A session that already holds its own credentials
552
- (written by you, or by `mcp-auth`/`mcp-sso`, whose output is a self-contained
553
- session file) keeps them, and only its refresh token is updated.
1028
+ Everything the broker writes — a `getProvider` provider's token and the
1029
+ token API's alike — is the secret alone: `authorizationToken` (or
1030
+ `sessionCookies`), `expiresAt`, `refreshToken`, with `issuedFor` and
1031
+ `issuedBy`, in one `saveSession`, and nothing else (see *Persistence and
1032
+ `flush()`*). Never the client secret, never `serviceUrl` or `authType`:
1033
+ `setConnectionConfig` and `setAuthorizationConfig` are not called. A store
1034
+ that merges a write into what it holds keeps a client or URL it held beside
1035
+ the secret; auth-stores 3 holds the secret alone.
554
1036
 
555
- With credentials in the service key, the stores return the stored refresh
556
- token through `loadSession()`, which the broker reads to seed the next
557
- process's provider — the XSUAA stores too, from auth-stores 1.2.3.
1037
+ The stored refresh token comes back through `loadSession()`, which the broker
1038
+ reads to seed the next process's provider.
558
1039
 
559
1040
  ### Token Providers
560
1041
 
561
- The package uses the `ITokenProvider` interface for token acquisition. Provider implementations live in `@mcp-abap-adt/auth-providers`:
562
-
563
- - **`ClientCredentialsProvider`** - For XSUAA authentication (reduced scope)
564
- - Uses client_credentials grant type
565
- - No browser interaction required
566
- - No refresh token provided
567
-
568
- - **`AuthorizationCodeProvider`** - For BTP/ABAP authentication (full scope)
569
- - How the login is conducted is an `authorization?: IAuthorizationStrategy<string>`, not a
570
- provider field. Omitted, it defaults to a browser callback on port `61001`
571
- - `browserCallbackStrategy({ browser?, port?, timeoutMs? })` from `@mcp-abap-adt/auth-providers`
572
- builds the ready-made strategy; pass `port` to avoid conflicts when running alongside other
573
- services (e.g. a proxy server) or to match a redirect URI registered at the identity provider
574
- - The callback port is held only for the duration of a login and released when it ends —
575
- by success, failure, timeout, or cancellation
576
- - Uses browser-based OAuth2 flow (if no refresh token)
577
- - Uses refresh token if available
578
- - Provides refresh token for future use
1042
+ Provider implementations live in `@mcp-abap-adt/auth-providers` 5. `getProvider`
1043
+ builds the one the destination states (see *A Provider for a Connector*); for
1044
+ the token API the 3.x way you pass one yourself — an `IRefreshableTokenProvider`
1045
+ or a factory:
579
1046
 
580
- **Example Usage:**
1047
+ - **`ClientCredentialsProvider`** — `client_credentials`: no user, no browser,
1048
+ no refresh token.
1049
+ - **`AuthorizationCodeProvider`** — `authorization_code`: refreshes by the
1050
+ stored refresh token, and logs in through the `authorization` strategy you
1051
+ give it — there is no default. `browserCallbackStrategy({ browser?, port?,
1052
+ timeoutMs? })` builds the usual one (callback port `61001` unless `port` is
1053
+ given; held only for the duration of a login).
581
1054
 
582
1055
  ```typescript
583
1056
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
@@ -589,7 +1062,7 @@ import { ClientCredentialsProvider } from '@mcp-abap-adt/auth-providers';
589
1062
 
590
1063
  // XSUAA, client_credentials: credentials from the service key
591
1064
  const xsuaaBroker = new AuthBroker({
592
- sessionStore: new XsuaaSessionStore('/path/to/sessions', 'https://mcp.example.com'),
1065
+ sessionStore: new XsuaaSessionStore('/path/to/sessions'),
593
1066
  serviceKeyStore: new XsuaaServiceKeyStore('/path/to/keys'),
594
1067
  provider: (destination, authConfig) => {
595
1068
  if (!authConfig) throw new Error(`No UAA credentials for ${destination}`);
@@ -602,339 +1075,56 @@ const xsuaaBroker = new AuthBroker({
602
1075
  });
603
1076
  ```
604
1077
 
605
- ### CLI: mcp-auth
606
-
607
- Generate or refresh `.env`/JSON output using AuthBroker + stores:
608
-
609
- ```bash
610
- mcp-auth <auth-code|oidc|saml2-pure|saml2-bearer> [options]
611
- mcp-auth --service-key <path> --output <path> [--env <path>] [--type abap|xsuaa] [--credential] [--browser auto|none|system|chrome|edge|firefox] [--format json|env]
612
- ```
613
-
614
- **Note**: The published CLI is compiled to `dist/bin` and does not require `tsx` at runtime. For repo usage, run `npm install` and `npm run build`.
615
-
616
- **Authentication Flow:**
617
- - Default: `authorization_code` (browser-based OAuth2)
618
- - `--credential`: `client_credentials` (clientId/clientSecret, no browser)
619
-
620
- **Browser Options (for authorization_code):**
621
- - `auto` (default): Try to open browser, fallback to showing URL
622
- - `none`: Show URL in console and wait for callback (no browser)
623
- - `system/chrome/edge/firefox`: Open specific browser
624
-
625
- `--browser` and `--redirect-port` are routed into `browserCallbackStrategy`. This CLI has no
626
- default of its own for the callback port: `--redirect-port` overrides it when given; omitted,
627
- the port comes from `auth-providers` (currently `61001`, chosen to sit above the ephemeral range
628
- and clear of the `3001`/`3333` range servers and proxies typically use). If you registered a
629
- redirect URI with a specific port at your identity provider, pass `--redirect-port` to match it.
630
- A login is given 5 minutes to complete (this is a person switching to a browser and signing in
631
- by hand, not an unattended caller).
632
-
633
- **SAML options (`saml2-pure`, `saml2-bearer`):**
634
- `mcp-auth` hands these subcommands to `mcp-sso` with every argument unchanged, so the SAML options
635
- are `mcp-sso`'s (see *CLI: mcp-sso* and *SAML assertion validation* below) and its exit code is
636
- `mcp-auth`'s. The ones a run needs:
637
-
638
- | Option | What it is |
639
- |---|---|
640
- | `--idp-metadata <url\|path>` | The identity provider's SAML metadata; fills `--idp-cert`, `--idp-entity-id` and `--idp-sso-url`. For SAP Cloud Identity Services: `https://<tenant>.accounts.ondemand.com/saml2/metadata`. |
641
- | `--idp-cert <path>`, `--idp-entity-id <id>` | The same trust, stated instead of read. |
642
- | `--idp-initiated` | The identity provider starts the login. `saml2-bearer` against XSUAA needs it. |
643
- | `--sp-entity-id`, `--acs-url` | The `Audience` and `Recipient`. For `saml2-bearer` with `--service-key`, read from `<uaa.url>/saml/metadata`. |
644
- | `--assertion <base64>`, `--assertion-flow <flow>` | A `SAMLResponse` obtained elsewhere, or how to obtain one. |
645
- | `--authn-request-id <id>` | The request an `--assertion` answers, when `mcp-sso` did not send it. |
646
-
647
- `saml2-bearer` still requires `--dev`: it has not been run against a live XSUAA with a SAML trust.
648
-
649
- **Examples:**
650
- ```bash
651
- # Auth code (default via service key)
652
- mcp-auth auth-code --service-key ./abap.json --output ./abap.env --type abap
653
-
654
- # OIDC SSO (device flow example)
655
- mcp-auth oidc --flow device --issuer https://issuer --client-id my-client --output ./sso.env --type xsuaa
656
-
657
- # SAML2 pure (cookie); the SAML flags are mcp-sso's, see "SAML assertion validation" below
658
- mcp-auth saml2-pure --idp-sso-url https://idp/sso --sp-entity-id my-sp --idp-cert ./idp-signing.pem --idp-entity-id https://idp.example/metadata --output ./saml.env --type abap
659
-
660
- # SAML2 bearer (in progress, requires --dev)
661
- mcp-auth saml2-bearer --dev --service-key ./mcp.json --idp-metadata https://<ias-tenant>.accounts.ondemand.com/saml2/metadata --idp-initiated --output ./sso.env --type xsuaa
662
-
663
- # ABAP: authorization_code (default, opens browser)
664
- mcp-auth --service-key ./abap.json --output ./abap.env --type abap
665
-
666
- # ABAP: authorization_code (show URL in console, no browser)
667
- mcp-auth --service-key ./abap.json --output ./abap.env --type abap --browser none
668
-
669
- # XSUAA: authorization_code (default)
670
- mcp-auth --service-key ./mcp.json --output ./mcp.env --type xsuaa
671
-
672
- # XSUAA: client_credentials (special cases)
673
- mcp-auth --service-key ./mcp.json --output ./mcp.env --type xsuaa --credential
674
-
675
- # Using existing .env for refresh token
676
- mcp-auth --env ./mcp.env --service-key ./mcp.json --output ./mcp.env --type xsuaa
677
- ```
678
-
679
- ### CLI: mcp-sso
680
-
681
- Get tokens via SSO providers (OIDC/SAML) and generate `.env`/JSON output:
682
-
683
- ```bash
684
- mcp-sso <oidc|saml2|bearer> [options]
685
- mcp-sso --protocol <oidc|saml2> --flow <flow> --output <path> [--type abap|xsuaa] [--format env|json] [--env <path>] [--config <path>]
686
- ```
687
-
688
- **Supported flows:**
689
- - OIDC: `browser`, `device`, `password`, `token_exchange`
690
- - SAML2: `bearer`, `pure`
691
-
692
- Only the flows that actually open a browser (OIDC `browser`; SAML2 `bearer`/`pure` with the
693
- default `--assertion-flow browser`) use `--browser` and `--redirect-port` — they are routed
694
- into `browserCallbackStrategy`/`oidcCallbackStrategy`/`samlCallbackStrategy`. This CLI has no
695
- default of its own for the callback port: `--redirect-port` overrides it when given; omitted,
696
- the port comes from `auth-providers` (currently `61001`). A login is given 5 minutes to
697
- complete. `device`, `password`, and `token_exchange` never open a browser from this process, so
698
- `--browser`/`--redirect-port` have no effect for them. This applies the same way whether
699
- `--protocol`/`--flow` come from CLI flags or from `--config` (below) — a field's origin doesn't
700
- change how it's handled, and CLI flags always take precedence over the same field in a file.
701
-
702
- **Examples:**
703
- ```bash
704
- # OIDC browser flow
705
- mcp-sso oidc --flow browser --issuer https://issuer --client-id my-client --output ./sso.env --type xsuaa
706
-
707
- # OIDC browser flow (manual code / OOB — no callback server is opened for this combination)
708
- mcp-sso oidc --flow browser --token-endpoint https://issuer/token --client-id my-client --code <auth_code> --redirect-uri urn:ietf:wg:oauth:2.0:oob --output ./sso.env --type xsuaa
709
-
710
- # OIDC device flow
711
- mcp-sso oidc --flow device --issuer https://issuer --client-id my-client --output ./sso.env --type xsuaa
712
-
713
- # OIDC password flow
714
- mcp-sso oidc --flow password --token-endpoint https://issuer/oauth/token --client-id my-client --username user --password pass --output ./sso.env --type xsuaa
715
-
716
- # OIDC token exchange
717
- mcp-sso oidc --flow token_exchange --issuer https://issuer --client-id my-client --subject-token <token> --output ./sso.env --type xsuaa
718
-
719
- # SAML bearer flow against XSUAA with a service key: the Audience, Recipient and token alias
720
- # come from <uaa.url>/saml/metadata, the IdP's trust from its own metadata
721
- mcp-sso bearer --service-key ./service-key.json --idp-metadata https://<ias-tenant>.accounts.ondemand.com/saml2/metadata --idp-initiated --output ./sso.env --type xsuaa
722
-
723
- # The same, every value stated (IdP-initiated assertion -> token)
724
- mcp-sso bearer --idp-sso-url https://idp/sso --sp-entity-id <uaa-entity-id> --acs-url <uaa-bearer-acs> --idp-cert ./idp-signing.pem --idp-entity-id https://idp.example/metadata --idp-initiated --token-endpoint https://uaa.example/oauth/token --assertion <base64> --output ./sso.env --type xsuaa
725
-
726
- # SAML pure flow (cookie; SP-initiated browser login, the request is sent by mcp-sso)
727
- mcp-sso saml2 --flow pure --idp-sso-url https://idp/sso --sp-entity-id my-sp --idp-cert ./idp-signing.pem --idp-entity-id https://idp.example/metadata --cookie "SAP_SESSION=..." --output ./sso.env --type abap
728
- ```
729
-
730
- **SAML assertion validation:**
731
- Both SAML flows validate every assertion before using it — signature, issuer, audience,
732
- recipient, time window, request ID and replay (done by `@mcp-abap-adt/auth-providers` 4; see its
733
- README, *SAML assertion validation*). The provider will not even be constructed without the
734
- trust it checks against, and `mcp-sso` invents none of it — it is stated, or read from SAML
735
- metadata:
736
-
737
- | Option | `--config` field | What it is |
738
- |---|---|---|
739
- | `--idp-cert <path>` (repeatable) | `idpCertificates` (string or list, inline PEM or base64 DER) | The identity provider's signing certificate(s). A file may be PEM (one or several certificates) or binary DER. Repeat the flag, or list several, to trust both keys during a rotation. |
740
- | `--idp-entity-id <id>` | `idpEntityId` | The identity provider's `entityID` — the `Issuer` its assertions carry. |
741
- | `--idp-metadata <url\|path>` | `idpMetadata` | The identity provider's SAML metadata (for SAP Cloud Identity Services `https://<tenant>.accounts.ondemand.com/saml2/metadata`). Fills the two rows above and `--idp-sso-url` where not given: signing keys and keys without `use`, never encryption keys. An https URL or a file; plain http only for loopback. Federation metadata (an `EntitiesDescriptor` of several entities) works too: entity ID, keys and SSO URL all come from the same identity provider, which `--idp-entity-id` names when there are several — without it such a run stops and lists them. |
742
- | `--sp-entity-id <id>` | `spEntityId` | Already required; it is now also the `Audience` the assertion must name. For bearer against UAA/XSUAA, the `entityID` in their SAML metadata. |
743
- | `--acs-url <url>` | `acsUrl` | The `Recipient` the assertion must name. For bearer against UAA/XSUAA, the token endpoint's bearer ACS; the default `http://localhost:<port>/callback` fits only a login delivered to this CLI. |
744
- | `--idp-initiated` | `idpInitiated` (`true`/`false`) | The identity provider starts the login and no AuthnRequest is sent, so the assertion must carry no `InResponseTo`. |
745
- | `--authn-request-id <id>` | `authnRequestId` | The AuthnRequest ID an `--assertion` answers, when the request was sent by something other than `mcp-sso`. |
746
-
747
- A `--idp-cert` on the command line replaces the file's `idpCertificates` rather than adding to
748
- them, so a certificate retired on the command line is not still trusted from the file.
749
-
750
- Which request setting a run needs:
751
-
752
- - **Browser or manual login, SP-initiated** (`--assertion-flow browser`, the default, or
753
- `manual`): nothing — `mcp-sso` builds the AuthnRequest and knows its ID.
754
- - **`--assertion <base64>`** from an SP-initiated login sent elsewhere: `--authn-request-id`.
755
- - **IdP-initiated** — required for `bearer` against UAA or XSUAA, whose saml2-bearer grant refuses
756
- an assertion carrying `InResponseTo`: `--idp-initiated`, with `--assertion`, or with
757
- `--assertion-flow manual` (the default under `--idp-initiated`), which asks you to start the
758
- login at the identity provider and paste the `SAMLResponse` it posts. `--idp-initiated` with
759
- `--assertion-flow browser` is refused, since there is no request URL to open.
760
-
761
- `--idp-initiated` together with `--authn-request-id`, a missing certificate or entity ID, or an
762
- assertion that fails a check is reported by `auth-providers` itself (`ValidationError` or
763
- `AssertionValidationError`), with the field or the check it refused.
764
-
765
- **XSUAA's side of a bearer run:**
766
- None of `--sp-entity-id`, `--acs-url` and the bearer token endpoint is in an XSUAA service key, but
767
- XSUAA publishes all three in its SAML metadata: its `entityID` is the `Audience`, and its
768
- `/oauth/token/alias/<alias>` endpoint is both the `Recipient` and where the assertion is exchanged.
769
- With `--service-key`, `bearer` reads `<uaa.url>/saml/metadata` and fills whichever of them was not
770
- given. Without network access to it, pass the file (from *Security > Trust Configuration >
771
- Download SAML Metadata* in the subaccount):
772
-
773
- ```bash
774
- mcp-sso bearer --saml-metadata ./saml-sp.xml --idp-sso-url https://idp/sso --sp-entity-id <uaa-entity-id> --acs-url <uaa-bearer-acs> --idp-cert ./idp-signing.pem --idp-entity-id https://idp.example/metadata --idp-initiated --assertion <base64> --service-key ./service-key.json --output ./sso.env --type xsuaa
775
- ```
776
-
777
- ### Local Keycloak (OIDC + SAML Tests)
778
-
779
- For local testing of `mcp-sso`, a ready-to-run Keycloak setup is included
780
- (OIDC browser/password/device + SAML assertion capture).
781
-
782
- ```bash
783
- cd tests/keycloak
784
- docker compose up -d
785
- ```
786
-
787
- Then use:
788
- ```bash
789
- node dist/bin/mcp-sso.js \
790
- oidc \
791
- --flow browser \
792
- --issuer http://localhost:8080/realms/mcp-sso \
793
- --client-id mcp-sso-cli \
794
- --scopes openid,profile,email \
795
- --output /tmp/keycloak.env \
796
- --type xsuaa
797
- ```
798
-
799
- See `tests/keycloak/README.md` for device flow and SAML examples.
800
-
801
- ### XSUAA Demo (CAP)
802
-
803
- A minimal CAP app for testing XSUAA flows is included at `tests/sso-demo`.
804
- It enables `authorization_code` and `saml2-bearer` grant types and provides a
805
- simple `CatalogService`. See `tests/sso-demo/readme.md` for deploy steps.
806
-
807
- **Config file:**
808
- You can pass a JSON file with provider config instead of (or alongside) `--protocol`/`--flow`
809
- and the OIDC/SAML flags — `--config` alone is enough to run a flow, with no other flags required:
810
-
811
- ```json
812
- {
813
- "protocol": "oidc",
814
- "flow": "device",
815
- "issuerUrl": "https://issuer",
816
- "clientId": "my-client",
817
- "scopes": ["openid", "profile"]
818
- }
819
- ```
820
-
821
- Any CLI flag given alongside `--config` overrides the same field in the file; a field the file
822
- sets and no flag overrides is used as-is. A file written for a pre-2.0.0 config still works:
823
- `browser` and `redirectPort` are routed into the strategy exactly as the equivalent CLI flags
824
- are, and `authorizationCode`/`assertionFlow` are honored the same way `--code`/`--assertion-flow`
825
- are. A file that sets `authorizationCodeProvider`, `assertionProvider`, or `manualInput` — all
826
- functions, which JSON cannot express — is refused with an error naming the CLI flag to use
827
- instead, rather than having the field silently dropped.
828
-
829
- A SAML config file carries the trust inline:
830
-
831
- ```json
832
- {
833
- "protocol": "saml2",
834
- "flow": "bearer",
835
- "idpSsoUrl": "https://idp.example/sso",
836
- "spEntityId": "https://uaa.example/entity",
837
- "acsUrl": "https://uaa.example/oauth/token/alias/example",
838
- "idpEntityId": "https://idp.example/metadata",
839
- "idpCertificates": ["MIIC...base64 DER from the IdP metadata's <X509Certificate>..."],
840
- "idpInitiated": true,
841
- "assertionFlow": "manual"
842
- }
843
- ```
844
-
845
- #### Migrating `mcp-sso` SAML runs from 2.2.0
846
-
847
- 2.2.0 used `@mcp-abap-adt/auth-providers` 2.x, which trusted any SAML payload it was handed.
848
- With 4.x every `mcp-sso` SAML run (`bearer`, `saml2 --flow pure`, and `mcp-auth saml2-pure` /
849
- `saml2-bearer`, which call it) fails before login until you add:
850
-
851
- 1. `--idp-metadata <url|path>`, or `--idp-cert <path>` and `--idp-entity-id <id>` (or
852
- `idpCertificates` and `idpEntityId` in `--config`) — without them the provider refuses to
853
- construct.
854
- 2. The real `--sp-entity-id` (the `Audience`) and, unless the assertion is delivered to this CLI's
855
- own callback, the `--acs-url` it names as `Recipient`. For `bearer` with `--service-key` both
856
- are read from XSUAA's metadata.
857
- 3. For `bearer` against UAA or XSUAA: `--idp-initiated`, with `--assertion` or
858
- `--assertion-flow manual`. For any other `--assertion` from an SP-initiated login:
859
- `--authn-request-id`.
860
-
861
- Node.js 22, 24 or 26 is required.
862
-
863
- ### Utility Script
864
-
865
- Generate `.env` files from service keys:
866
-
867
- ```bash
868
- npm run generate-env <destination> [service-key-path] [session-path]
869
- ```
870
-
871
1078
  ## Testing
872
1079
 
873
- Tests are located in `src/__tests__/` and use Jest as the test runner.
874
-
875
- ### Running Tests
1080
+ The library's tests are in `packages/auth-broker/src/__tests__/` and use Jest.
1081
+ Run them from the repository root, where the dev dependencies are installed:
876
1082
 
877
1083
  ```bash
878
- # Run all tests
1084
+ # Every workspace's tests (library and CLI)
879
1085
  npm test
880
1086
 
881
- # Run specific test file (all tests in that file)
882
- npm test -- getToken.test.ts
883
- npm test -- refreshToken.test.ts
884
-
885
- # Run specific test by name/pattern
886
- npm test -- getToken.test.ts -t "Test 1"
887
- npm test -- getToken.test.ts -t "Test 2"
888
- npm test -- getToken.test.ts -t "Test 3"
889
-
890
- # Run test group (e.g., all getToken tests)
891
- npm test -- getToken.test.ts
1087
+ # The library's only
1088
+ npm test -w @mcp-abap-adt/auth-broker
892
1089
 
893
- # Note: Test 2 requires Test 1 to pass first (test1Passed flag)
894
- # To run Test 2 alone, you may need to run all tests in the file:
895
- npm test -- getToken.test.ts
1090
+ # One file, or one case
1091
+ npm test -w @mcp-abap-adt/auth-broker -- AuthBroker.test.ts
1092
+ npm test -w @mcp-abap-adt/auth-broker -- AuthBroker.test.ts -t "name of the case"
896
1093
  ```
897
1094
 
898
- ### Test Structure
899
-
900
- Tests are designed to run sequentially (guaranteed by `maxWorkers: 1` and `maxConcurrency: 1` in `jest.config.js`):
901
-
902
- 1. **Test 1**: Verifies error handling for non-existent destination (`NO_EXISTS`)
903
- - Requires: `NO_EXISTS.json` should NOT exist
904
-
905
- 2. **Test 2**: Tests browser authentication when service key exists but `.env` file doesn't
906
- - Requires: `TRIAL.json` must exist, `TRIAL.env` should NOT exist
907
- - Will open browser for OAuth authentication
908
-
909
- 3. **Test 3**: Tests token refresh using existing `.env` file
910
- - Requires: `TRIAL.json` and `TRIAL.env` must exist
911
- - Can run independently if `.env` file exists (created manually or by Test 2)
1095
+ Tests run sequentially (`maxWorkers: 1` and `maxConcurrency: 1` in
1096
+ `packages/auth-broker/jest.config.js`). `AuthBroker.test.ts` needs nothing;
1097
+ `AuthBroker.integration.test.ts` reads real service keys and sessions, and
1098
+ every case in it returns early unless the configuration below names a real
1099
+ destination.
912
1100
 
913
1101
  ### Test Setup
914
1102
 
915
- 1. Copy `tests/test-config.yaml.template` to `tests/test-config.yaml`
1103
+ 1. Copy `packages/auth-broker/tests/test-config.yaml.template` to
1104
+ `packages/auth-broker/tests/test-config.yaml` (until the workspace layout it
1105
+ lived in `tests/` at the repository root; move an existing copy)
916
1106
  2. Fill in configuration values (paths, destinations, MCP URL for XSUAA)
917
1107
  3. Place service key files in configured `service_keys_dir`:
918
1108
  - `{destination}.json` for ABAP tests (e.g., `trial.json`)
919
1109
  - `{btp_destination}.json` for XSUAA tests (e.g., `btp.json`)
920
1110
 
921
- Tests will automatically skip if required files are missing or configuration contains placeholders.
1111
+ Without `test-config.yaml` the template is read, its placeholders disable every
1112
+ integration case, and the run reaches no system.
922
1113
 
923
1114
  ## Documentation
924
1115
 
925
- Complete documentation is available in the [`docs/`](docs/) directory:
1116
+ Complete documentation is available in the repository's [`docs/`](../../docs/) directory:
926
1117
 
927
- - **[Architecture](docs/architecture/ARCHITECTURE.md)** - System architecture and design decisions
928
- - **[Development](docs/development/)** - Testing methodology and development roadmap
929
- - **[Development Roadmap](docs/development/DEVELOPMENT_ROADMAP.md)** - Development roadmap and future plans
930
- - **[Installation](docs/installing/INSTALLATION.md)** - Installation and setup guide
931
- - **[Usage](docs/using/USAGE.md)** - API reference and usage examples
1118
+ - **[Architecture](../../docs/architecture/ARCHITECTURE.md)** - System architecture and design decisions
1119
+ - **[Testing](../../docs/development/TESTING.md)** - Where the suites live, what they need, the release checks
1120
+ - **[Installation](../../docs/installing/INSTALLATION.md)** - Installation and setup guide
1121
+ - **[Usage](../../docs/using/USAGE.md)** - API reference and usage examples
932
1122
 
933
- See [docs/README.md](docs/README.md) for the complete documentation index.
1123
+ See [docs/README.md](../../docs/README.md) for the complete documentation index.
934
1124
 
935
1125
  ## Contributors
936
1126
 
937
- Thank you to all contributors! See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the complete list.
1127
+ Thank you to all contributors! See [CONTRIBUTORS.md](../../CONTRIBUTORS.md) for the complete list.
938
1128
 
939
1129
  ## License
940
1130