@mcp-abap-adt/auth-broker 3.1.0 → 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.
package/README.md CHANGED
@@ -1,16 +1,23 @@
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
- The `mcp-auth` and `mcp-sso` commands that write session files are in
12
- [`@mcp-abap-adt/auth-broker-cli`](../auth-broker-cli/README.md), in the same
13
- repository. Up to 3.0.4 they shipped in this package; from 3.1.0 this package
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
14
21
  has no `bin`. Install the commands with
15
22
  `npm i -g @mcp-abap-adt/auth-broker-cli` — after
16
23
  `npm uninstall -g @mcp-abap-adt/auth-broker` if you had installed this one
@@ -18,8 +25,12 @@ globally for them.
18
25
 
19
26
  ## Features
20
27
 
21
- - 🎯 **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
22
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
23
34
  - ⚡ **Forced refresh**: `refreshToken()` obtains a new token even when the cached one looks valid — for a caller holding a 401
24
35
  - 🧾 **JWT or SAML cookies**: what the provider returns is saved as a token or as session cookies
25
36
  - 🔑 **No secrets copied**: The client secret stays in the service key; the session store gets tokens only
@@ -38,10 +49,13 @@ supported as well.
38
49
 
39
50
  ### Basic Usage
40
51
 
41
- The broker takes a session store, an optional service key store, and a token
42
- 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
43
55
  `@mcp-abap-adt/interfaces-auth`) — or a factory that builds one per
44
- 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.
45
59
 
46
60
  ```typescript
47
61
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
@@ -85,18 +99,358 @@ The factory receives:
85
99
  - `connConfig` — the session's connection config, with `serviceUrl` resolved
86
100
  (from the session, else the service key) and the token stored last.
87
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
+
88
107
  A provider instance can be passed instead of a factory; it is then used as
89
108
  given, for every destination, and the broker seeds it with nothing:
90
109
 
91
110
  ```typescript
92
111
  const broker = new AuthBroker({
93
112
  sessionStore: new AbapSessionStore('/path/to/sessions'),
94
- provider: new AuthorizationCodeProvider({ uaaUrl, clientId, clientSecret }),
113
+ provider: new AuthorizationCodeProvider({
114
+ uaaUrl, clientId, clientSecret,
115
+ authorization: browserCallbackStrategy({ browser: 'system' }),
116
+ }),
117
+ });
118
+ ```
119
+
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);
95
448
  });
96
449
  ```
97
450
 
98
- > `AuthorizationCodeProvider` and the other `@mcp-abap-adt/auth-providers`
99
- > providers implement `IRefreshableTokenProvider` from auth-providers 4.2.0.
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()`.
100
454
 
101
455
  ### Headless Processes (No Browser)
102
456
 
@@ -129,6 +483,34 @@ try {
129
483
  A cached token that is still valid, or a refresh token the UAA accepts, never
130
484
  reaches the strategy.
131
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
+
132
514
  ### Custom Browser Auth Port
133
515
 
134
516
  How a login is conducted — including which port the local OAuth2 callback
@@ -156,41 +538,118 @@ const token = await broker.getToken('TRIAL');
156
538
  const newToken = await broker.refreshToken('TRIAL');
157
539
  ```
158
540
 
159
- Both write the result to the session store: a SAML result as session cookies,
160
- anything else as the bearer token, and the refresh token when the result has
161
- 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.
162
595
 
163
596
  ### Creating Token Refresher for DI
164
597
 
165
- 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.
166
604
 
167
605
  ```typescript
168
- import { AuthBroker } from '@mcp-abap-adt/auth-broker';
169
- import { JwtAbapConnection } from '@mcp-abap-adt/connection';
170
-
171
- // Create broker
172
- const broker = new AuthBroker({
173
- sessionStore: mySessionStore,
174
- serviceKeyStore: myServiceKeyStore,
175
- provider: myProviderFactory,
176
- });
177
-
178
- // Create token refresher for specific destination
179
606
  const tokenRefresher = broker.createTokenRefresher('TRIAL');
180
607
 
181
- // Inject into connection (connection can handle 401/403 automatically)
182
- const connection = new JwtAbapConnection(config, tokenRefresher);
183
-
184
- // Token refresher methods:
185
- // - getToken(): the provider's current token (broker.getToken)
186
- // - 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();
187
612
  ```
188
613
 
189
- **Benefits of Token Refresher:**
190
- - 🔄 **Transparent Refresh**: Connection handles 401/403 errors automatically
191
- - 🧩 **Dependency Injection**: Clean separation of concerns
192
- - 💾 **Automatic Persistence**: Tokens saved to session store after refresh
193
- - 🎯 **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.
194
653
 
195
654
  ## Migrating from 2.2.0
196
655
 
@@ -218,111 +677,72 @@ const connection = new JwtAbapConnection(config, tokenRefresher);
218
677
 
219
678
  ### Environment Variables
220
679
 
221
- #### Configuration Variables
222
-
223
- - `AUTH_BROKER_PATH` - Colon/semicolon-separated paths for searching `.env` and `.json` files (default: current working directory)
224
-
225
- #### Debugging Variables
226
-
227
- - `DEBUG_BROKER` - Enable debug logging for `auth-broker` package (short name)
228
- - Set to `true` to enable logging (default: `false`)
229
- - When enabled, logs authentication steps, token operations, and error details
230
- - Can be explicitly disabled by setting to `false`
231
- - Example: `DEBUG_BROKER=true npm test`
232
-
233
- - `DEBUG_AUTH_BROKER` - Long name (backward compatibility)
234
- - Same as `DEBUG_BROKER`, but longer name
235
- - Example: `DEBUG_AUTH_BROKER=true npm test`
236
-
237
- - `LOG_LEVEL` - Control log verbosity level
238
- - Values: `debug`, `info`, `warn`, `error` (default: `info`)
239
- - `debug` - All messages including detailed debug information
240
- - `info` - Informational messages, warnings, and errors
241
- - `warn` - Warnings and errors only
242
- - `error` - Errors only
243
- - Example: `LOG_LEVEL=debug DEBUG_BROKER=true npm test`
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`.)
244
685
 
245
- - `DEBUG` - Alternative way to enable debugging
246
- - Set to `true` to enable all debug logging
247
- - Or set to a string containing `broker` or `auth-broker` to enable only this package
248
- - Example: `DEBUG=true npm test` or `DEBUG=broker npm test` or `DEBUG=auth-broker npm test`
686
+ ### Logging
249
687
 
250
- **Note**: For debugging related packages:
251
- - `DEBUG_STORES` (short) or `DEBUG_AUTH_STORES` (long) - Enable logging for `@mcp-abap-adt/auth-stores` package
252
- - `DEBUG_PROVIDER` (short) or `DEBUG_AUTH_PROVIDERS` (long) - Enable logging for `@mcp-abap-adt/auth-providers` package
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
- **Legacy Support**: `DEBUG_AUTH_LOG` is still supported for backward compatibility (equivalent to `DEBUG_BROKER=true LOG_LEVEL=debug`)
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
- ### Logging Features
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.
257
701
 
258
- When logging is enabled (via `DEBUG_BROKER=true` or `DEBUG_AUTH_BROKER=true`), the broker provides detailed structured logging:
702
+ With `DefaultLogger` at `info`:
259
703
 
260
- **What is logged:** broker initialization (which stores, instance or factory),
261
- provider builds (whether credentials, a refresh token and a stored token were
262
- there), and each token saved (token type, grant, whether a refresh token came
263
- back, expiry). Store read failures are logged as warnings.
264
-
265
- **What is never logged:** any part of a token, refresh token or secret — not a
266
- prefix, not a suffix. A log line says whether a token is there, never what it is.
267
-
268
- Example output with `DEBUG_BROKER=true LOG_LEVEL=info`:
269
704
  ```
270
- [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}
271
707
  ```
272
708
 
273
- **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.
274
-
275
709
  ### File Structure
276
710
 
277
- #### Environment File for ABAP (`{destination}.env`)
278
-
279
- 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):
280
715
 
281
716
  ```env
717
+ # The means — EnvDestinationStore (ABAP_DESTINATION_VARS); the broker never writes them
282
718
  SAP_URL=https://your-system.abap.us10.hana.ondemand.com
283
719
  SAP_CLIENT=100
284
- SAP_JWT_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
285
- SAP_REFRESH_TOKEN=refresh_token_string
720
+ SAP_AUTH_TYPE=jwt
721
+ SAP_GRANT_TYPE=authorization_code
286
722
  SAP_UAA_URL=https://your-account.authentication.us10.hana.ondemand.com
287
723
  SAP_UAA_CLIENT_ID=client_id
288
724
  SAP_UAA_CLIENT_SECRET=client_secret
289
- ```
290
-
291
- #### Environment File for XSUAA (`{destination}.env`)
292
-
293
- For XSUAA connections (reduced scope), use `XSUAA_*` environment variables:
294
-
295
- ```env
296
- XSUAA_MCP_URL=https://your-mcp-server.cfapps.eu10.hana.ondemand.com
297
- XSUAA_JWT_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
298
- XSUAA_REFRESH_TOKEN=refresh_token_string
299
- XSUAA_UAA_URL=https://your-account.authentication.eu10.hana.ondemand.com
300
- XSUAA_UAA_CLIENT_ID=client_id
301
- XSUAA_UAA_CLIENT_SECRET=client_secret
302
- ```
303
-
304
- **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.
305
725
 
306
- #### Environment File for BTP (`{destination}.env`)
307
-
308
- For BTP connections (full scope for ABAP systems), use `BTP_*` environment variables:
309
-
310
- ```env
311
- BTP_ABAP_URL=https://your-system.abap.us10.hana.ondemand.com
312
- BTP_JWT_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
313
- BTP_REFRESH_TOKEN=refresh_token_string
314
- BTP_UAA_URL=https://your-account.authentication.eu10.hana.ondemand.com
315
- BTP_UAA_CLIENT_ID=client_id
316
- BTP_UAA_CLIENT_SECRET=client_secret
317
- BTP_SAP_CLIENT=100
318
- 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
319
732
  ```
320
733
 
321
- **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).
322
739
 
323
- #### Service Key File for ABAP (`{destination}.json`)
740
+ #### Service Key File (`{destination}.json`)
324
741
 
325
- 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' })`.
326
746
 
327
747
  ```json
328
748
  {
@@ -335,40 +755,9 @@ Standard ABAP service key format:
335
755
  }
336
756
  ```
337
757
 
338
- #### Service Key File for XSUAA (`{destination}.json`)
339
-
340
- Direct XSUAA service key format (from BTP):
341
-
342
- ```json
343
- {
344
- "url": "https://your-account.authentication.eu10.hana.ondemand.com",
345
- "apiurl": "https://api.authentication.eu10.hana.ondemand.com",
346
- "clientid": "your_client_id",
347
- "clientsecret": "your_client_secret"
348
- }
349
- ```
350
-
351
- **Note**: For XSUAA service keys, `apiurl` is prioritized over `url` for UAA authorization if present.
352
-
353
- ## XSUAA vs BTP Authentication
354
-
355
- This package supports two types of BTP authentication:
356
-
357
- ### XSUAA (Reduced Scope)
358
- - **Purpose**: Access BTP services with limited scopes
359
- - **Service Key**: Contains only UAA credentials (no ABAP URL)
360
- - **Session Store**: `XsuaaSessionStore` (uses `XSUAA_*` environment variables)
361
- - **Authentication**: Client credentials grant type (no browser required)
362
- - **MCP URL**: Optional, provided separately (from YAML config `mcp_url`, parameter, or request header)
363
- - **Use Case**: Accessing BTP services like MCP servers with reduced permissions
364
-
365
- ### BTP (Full Scope for ABAP)
366
- - **Purpose**: Access ABAP systems with full roles and scopes
367
- - **Service Key**: Contains UAA credentials and ABAP URL
368
- - **Session Store**: `BtpSessionStore` (uses `BTP_*` environment variables)
369
- - **Authentication**: Browser-based OAuth2 (like ABAP) or refresh token
370
- - **ABAP URL**: Required, from service key or YAML configuration
371
- - **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`.
372
761
 
373
762
  ## Responsibilities and Design Principles
374
763
 
@@ -377,7 +766,7 @@ This package supports two types of BTP authentication:
377
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**.
378
767
 
379
768
  This means:
380
- - 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`
381
770
  - Does not know about internal data structures or methods not defined in interfaces
382
771
  - Does not make assumptions about implementation behavior beyond interface contracts
383
772
  - Does not access properties or methods not explicitly defined in interfaces
@@ -394,11 +783,11 @@ The `@mcp-abap-adt/auth-broker` package defines **interfaces** and provides **or
394
783
 
395
784
  #### What AuthBroker Does
396
785
 
397
- - **Resolves what the stores hold**: the service URL, the UAA credentials, the stored token and refresh token
398
- - **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
399
788
  - **Asks the provider once**: `getTokens()` for `getToken()`, `refreshTokens()` for `refreshToken()` — no retries, no fallbacks
400
- - **Persists the answer**: token or session cookies, and the refresh token, to `sessionStore`
401
- - **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`
402
791
 
403
792
  #### What AuthBroker Does NOT Do
404
793
 
@@ -412,19 +801,15 @@ The `@mcp-abap-adt/auth-broker` package defines **interfaces** and provides **or
412
801
 
413
802
  The **consumer** (application using `AuthBroker`) is responsible for:
414
803
 
415
- 1. **Selecting appropriate implementations**: Choose the correct `IServiceKeyStore`, `ISessionStore`, and `IRefreshableTokenProvider` implementations based on the use case:
416
- - **ABAP systems**: Use `AbapServiceKeyStore`, `AbapSessionStore` (or `SafeAbapSessionStore`), and `AuthorizationCodeProvider`
417
- - **BTP systems**: Use `AbapServiceKeyStore`, `BtpSessionStore` (or `SafeBtpSessionStore`), and `AuthorizationCodeProvider`
418
- - **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
419
809
 
420
- 2. **Ensuring complete configuration**: If a session store requires `serviceUrl` (e.g., `AbapSessionStore` requires `sapUrl`), the consumer must ensure that:
421
- - The session is created with `serviceUrl` before calling `AuthBroker.getToken()`, OR
422
- - 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.
423
811
 
424
- 3. **Understanding store requirements**: Different session store implementations have different requirements:
425
- - `AbapSessionStore`: Requires `sapUrl` (maps to `serviceUrl` in `IConnectionConfig`)
426
- - `BtpSessionStore`: Does not require `serviceUrl` (uses `mcpUrl` instead)
427
- - `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`.
428
813
 
429
814
  ### Store Responsibilities
430
815
 
@@ -432,10 +817,7 @@ Concrete `ISessionStore` implementations are responsible for:
432
817
 
433
818
  - **Handling their own data format**: Each store knows its internal data format (e.g., `AbapSessionData`, `BtpBaseSessionData`)
434
819
  - **Converting between formats**: Converting between `IConfig`/`IConnectionConfig` and internal storage format
435
- - **Managing required fields**: If a store requires `serviceUrl` (e.g., `AbapSessionStore`), it should:
436
- - Retrieve it from `serviceKeyStore` if not provided in `IConnectionConfig`, OR
437
- - Use existing value from current session if available, OR
438
- - 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)
439
821
 
440
822
  ### Provider Responsibilities
441
823
 
@@ -449,7 +831,7 @@ Concrete `IRefreshableTokenProvider` implementations are responsible for:
449
831
  ### Design Principles
450
832
 
451
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)
452
- 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`
453
835
  3. **Single Responsibility**: Each component has a single, well-defined responsibility:
454
836
  - `AuthBroker`: Orchestration — resolving, asking, persisting
455
837
  - `ISessionStore`: Session data storage and retrieval
@@ -468,54 +850,134 @@ Concrete `IRefreshableTokenProvider` implementations are responsible for:
468
850
  new AuthBroker(
469
851
  config: {
470
852
  sessionStore: ISessionStore; // required
471
- serviceKeyStore?: IServiceKeyStore; // optional
472
- provider: // required
853
+ serviceKeyStore?: IServiceKeyStore; // getProvider needs it
854
+ provider?: // the token API's, optional
473
855
  | IRefreshableTokenProvider
474
856
  | ((
475
857
  destination: string,
476
858
  authConfig: IAuthorizationConfig | null,
477
859
  connConfig: IConnectionConfig,
478
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
479
868
  },
480
869
  logger?: ILogger,
481
870
  )
482
871
  ```
483
872
 
484
873
  **Parameters:**
485
- - `config.sessionStore` - **Required** - Where tokens and the refresh token are kept. Its `serviceUrl`, or the service key's, is required.
486
- - `config.serviceKeyStore` - **Optional** - UAA credentials and the service URL.
487
- - `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.
488
883
  - `logger` - Optional logger. If not provided, nothing is logged.
489
884
 
490
885
  **Available Implementations:**
491
- - **ABAP**: `AbapServiceKeyStore(directory, logger?)`, `AbapSessionStore(directory, logger?, defaultServiceUrl?)`, `SafeAbapSessionStore(logger?, defaultServiceUrl?)`, `AuthorizationCodeProvider(...)`
492
- - **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
493
889
 
494
890
  #### Methods
495
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
+
496
907
  ##### `getToken(destination: string): Promise<string>`
497
908
 
498
- 1. Resolves the destination's `serviceUrl` (session, else service key; an error if neither has one).
499
- 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.
500
- 3. Calls `provider.getTokens()` once. The provider answers from its cache, refreshes, or logs in.
501
- 4. Persists the result: `sessionCookies` for `tokenType: 'saml'`, else `authorizationToken`; the refresh token when the result has one.
502
- 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.
503
913
 
504
914
  ##### `refreshToken(destination: string): Promise<string>`
505
915
 
506
916
  The same, with `provider.refreshTokens()`: a new token, never the cached one —
507
- 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.
508
919
 
509
920
  ##### `getAuthorizationConfig(destination)` / `getConnectionConfig(destination)`
510
921
 
511
- 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.
512
934
 
513
935
  ##### `createTokenRefresher(destination): ITokenRefresher`
514
936
 
515
- `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.
516
938
 
517
939
  ##### Error Handling
518
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
+
519
981
  - **Provider errors propagate unchanged** — the same object, with its class,
520
982
  `code`, `missingFields` and `cause`: auth-providers' `ValidationError`,
521
983
  `BrowserAuthError`, `AssertionValidationError`, network errors (`ECONNREFUSED`,
@@ -532,12 +994,25 @@ The session's configuration, else the service key's, else `null`.
532
994
  the new token was written. (The session stores of
533
995
  auth-stores answer an unreadable session file with `null` themselves, so it
534
996
  reads as absent before the broker sees it.)
535
- - **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.
536
1001
  - **A provider result without a token** is an error.
537
1002
 
538
1003
  ```typescript
1004
+ import { DestinationConfigError } from '@mcp-abap-adt/auth-broker';
539
1005
  import { ValidationError } from '@mcp-abap-adt/auth-providers';
540
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
+
541
1016
  try {
542
1017
  const token = await broker.getToken('TRIAL');
543
1018
  } catch (error) {
@@ -550,38 +1025,32 @@ try {
550
1025
 
551
1026
  #### Secrets in the Session Store
552
1027
 
553
- The broker writes the token (or session cookies) and the refresh token to the
554
- session store — never the client secret. When the credentials come from the
555
- service key they stay there. A session that already holds its own credentials
556
- (written by you, or by `mcp-auth`/`mcp-sso`, whose output is a self-contained
557
- 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.
558
1036
 
559
- With credentials in the service key, the stores return the stored refresh
560
- token through `loadSession()`, which the broker reads to seed the next
561
- 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.
562
1039
 
563
1040
  ### Token Providers
564
1041
 
565
- The package uses the `ITokenProvider` interface for token acquisition. Provider implementations live in `@mcp-abap-adt/auth-providers`:
566
-
567
- - **`ClientCredentialsProvider`** - For XSUAA authentication (reduced scope)
568
- - Uses client_credentials grant type
569
- - No browser interaction required
570
- - No refresh token provided
571
-
572
- - **`AuthorizationCodeProvider`** - For BTP/ABAP authentication (full scope)
573
- - How the login is conducted is an `authorization?: IAuthorizationStrategy<string>`, not a
574
- provider field. Omitted, it defaults to a browser callback on port `61001`
575
- - `browserCallbackStrategy({ browser?, port?, timeoutMs? })` from `@mcp-abap-adt/auth-providers`
576
- builds the ready-made strategy; pass `port` to avoid conflicts when running alongside other
577
- services (e.g. a proxy server) or to match a redirect URI registered at the identity provider
578
- - The callback port is held only for the duration of a login and released when it ends —
579
- by success, failure, timeout, or cancellation
580
- - Uses browser-based OAuth2 flow (if no refresh token)
581
- - Uses refresh token if available
582
- - 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:
583
1046
 
584
- **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).
585
1054
 
586
1055
  ```typescript
587
1056
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
@@ -593,7 +1062,7 @@ import { ClientCredentialsProvider } from '@mcp-abap-adt/auth-providers';
593
1062
 
594
1063
  // XSUAA, client_credentials: credentials from the service key
595
1064
  const xsuaaBroker = new AuthBroker({
596
- sessionStore: new XsuaaSessionStore('/path/to/sessions', 'https://mcp.example.com'),
1065
+ sessionStore: new XsuaaSessionStore('/path/to/sessions'),
597
1066
  serviceKeyStore: new XsuaaServiceKeyStore('/path/to/keys'),
598
1067
  provider: (destination, authConfig) => {
599
1068
  if (!authConfig) throw new Error(`No UAA credentials for ${destination}`);
@@ -647,8 +1116,7 @@ integration case, and the run reaches no system.
647
1116
  Complete documentation is available in the repository's [`docs/`](../../docs/) directory:
648
1117
 
649
1118
  - **[Architecture](../../docs/architecture/ARCHITECTURE.md)** - System architecture and design decisions
650
- - **[Development](../../docs/development/)** - Testing methodology and development roadmap
651
- - **[Development Roadmap](../../docs/development/DEVELOPMENT_ROADMAP.md)** - Development roadmap and future plans
1119
+ - **[Testing](../../docs/development/TESTING.md)** - Where the suites live, what they need, the release checks
652
1120
  - **[Installation](../../docs/installing/INSTALLATION.md)** - Installation and setup guide
653
1121
  - **[Usage](../../docs/using/USAGE.md)** - API reference and usage examples
654
1122