@mcp-abap-adt/auth-broker 3.1.0 → 4.1.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 (39) hide show
  1. package/CHANGELOG.md +531 -0
  2. package/README.md +1062 -220
  3. package/dist/AuthBroker.d.ts +249 -30
  4. package/dist/AuthBroker.d.ts.map +1 -1
  5. package/dist/AuthBroker.js +681 -115
  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 +117 -0
  13. package/dist/binding.d.ts.map +1 -0
  14. package/dist/binding.js +223 -0
  15. package/dist/bindingOf.d.ts +37 -0
  16. package/dist/bindingOf.d.ts.map +1 -0
  17. package/dist/bindingOf.js +53 -0
  18. package/dist/clientAuthentication.d.ts +109 -0
  19. package/dist/clientAuthentication.d.ts.map +1 -0
  20. package/dist/clientAuthentication.js +218 -0
  21. package/dist/destinations.d.ts +164 -0
  22. package/dist/destinations.d.ts.map +1 -0
  23. package/dist/destinations.js +473 -0
  24. package/dist/index.d.ts +10 -4
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +14 -7
  27. package/dist/providers/ITokenProvider.d.ts +2 -1
  28. package/dist/providers/ITokenProvider.d.ts.map +1 -1
  29. package/dist/providers/index.d.ts +2 -2
  30. package/dist/providers/index.js +2 -2
  31. package/dist/stores/index.d.ts +2 -2
  32. package/dist/stores/index.js +2 -2
  33. package/dist/stores/interfaces.d.ts +4 -3
  34. package/dist/stores/interfaces.d.ts.map +1 -1
  35. package/dist/stores/interfaces.js +2 -2
  36. package/dist/types.d.ts +5 -2
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/types.js +3 -1
  39. package/package.json +16 -8
package/CHANGELOG.md CHANGED
@@ -11,6 +11,537 @@ Thank you to all contributors! See [CONTRIBUTORS.md](../../CONTRIBUTORS.md) for
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [4.1.0] - 2026-10-05
15
+
16
+ **x509 service keys.** A client that authenticates to the authorization
17
+ server with a certificate instead of a secret — an XSUAA service key created
18
+ with `{"credential-type": "x509"}` — when the consumer says so, through a
19
+ strategy. Additive: without the new option every destination is served
20
+ exactly as by 4.0.0, and nothing certificate-related is read. Released with
21
+ `@mcp-abap-adt/auth-broker-cli` 2.1.0.
22
+
23
+ ### Added
24
+
25
+ - **`AuthBrokerConfig.clientAuthentication`** — a `ClientAuthenticationStrategy`
26
+ `(context) => Promise<IClientAuthentication>`: how the client of every grant
27
+ that authenticates one (`authorization_code`, `client_credentials`,
28
+ `passcode`, `oidc_authorization_code`, `device_code`, `password`,
29
+ `token_exchange`, `saml2_bearer` — `ClientAuthenticationGrant`)
30
+ authenticates. Called once per build with a `ClientAuthenticationContext`
31
+ (`destination`, `grant`, the key store's secret `client` — `uaaUrl`,
32
+ `uaaClientId`, `uaaClientSecret` only, never a refresh token — or `null`, and a
33
+ lazy, memoised `readCertificate()`); its answer goes to the provider as
34
+ `clientAuthentication`, with no client secret. No default and no "nothing"
35
+ answer: a stated choice never falls back to the secret.
36
+ - **`fromServiceKeyCertificate()`** — auth-providers' `tlsClientCertificate`
37
+ from the key store's certificate client, against `<certUrl>/oauth/token`
38
+ (a trailing `/` of `certUrl` dropped), the material checked before it
39
+ answers. **`fromServiceKeySecret({ encoding: 'raw' | 'form' })`** —
40
+ `clientSecretBasic` with the secret client; `encoding` required (`raw` for
41
+ XSUAA, measured). Each fails closed; a fallback is the consumer's
42
+ composition, decided by what the key holds (`readCertificate()`), never by
43
+ catching a refusal.
44
+ - **The certificate client** is read through the optional
45
+ `IServiceKeyStore.getClientCertificate` (`@mcp-abap-adt/interfaces-auth-broker`
46
+ 1.2.0) — auth-stores 3.3.0 answers it for an x509 XSUAA key and for an
47
+ `EnvDestinationStore` stating `SAP_UAA_CLIENT_CERT_PATH`,
48
+ `SAP_UAA_CLIENT_KEY_PATH` and `SAP_UAA_CERT_URL` (`XSUAA_UAA_*`) — only
49
+ when the strategy asks, at most once per build. The row's client is then
50
+ its identity: the secret client's `uaaUrl` / `uaaClientId`, else the
51
+ certificate client's.
52
+ - **The token API factory's fourth argument, `TokenProviderClient`** —
53
+ beside a strategy, for a grant that authenticates a client: the strategy's
54
+ `clientAuthentication`, the client identity (`uaaUrl`, `clientId`) and the
55
+ stored `refreshToken` when the session is bound here — taken only from the
56
+ session read whose binding was checked, never from a client's own read.
57
+ Never a certificate, key or secret. Additive: a 4.0.0 factory keeps working.
58
+ - **Fixed-words refusals**: whatever a strategy, a store it reads or the
59
+ certificate check throws — and an answer that is no `IClientAuthentication`
60
+ — is a `DestinationConfigError` naming `clientAuthentication` before any
61
+ provider exists, its words chosen by the error's class (no certificate, no
62
+ secret, incomplete / expired / unusable certificate, no client
63
+ authentication, the certificate could not be read, the strategy failed); a
64
+ factory that throws beside a strategy is one naming `provider`,
65
+ `clientAuthentication`. No `cause`, nothing of the thrown message, no PEM
66
+ anywhere — log, refusal, error, session store.
67
+ - Exports: `fromServiceKeyCertificate`, `fromServiceKeySecret`, and the
68
+ types `ClientAuthenticationStrategy`, `ClientAuthenticationContext`,
69
+ `ClientAuthenticationGrant`, `FromServiceKeySecretOptions`,
70
+ `TokenProviderClient`, `IClientCertificate`, `IClientAuthentication`.
71
+ - `npm run test:live:x509`: the live check against a BTP subaccount (exact
72
+ `cf target` guard, a GUID ledger, teardown also on failure). Not in CI.
73
+
74
+ ### Changed
75
+
76
+ - **A client row with no client and no strategy names the way out.** The
77
+ `DestinationConfigError` of a UAA, OIDC or `saml2_bearer` destination whose
78
+ key store answers no client (or one without a client id) keeps its
79
+ `missingFields`; its message adds `; a certificate client needs a
80
+ clientAuthentication strategy`.
81
+
82
+ ### On the strategy path (only with `clientAuthentication`)
83
+
84
+ - **The binding is the client's identity**: `issuedBy` from the identity's
85
+ `uaaUrl` and `client_id`, so a certificate destination's tokens are reused
86
+ after the broker is recreated and dropped when the issuer or client id
87
+ changes — and a secret key and an x509 key of the same client id share one
88
+ session (switching keeps its token and refresh token).
89
+ - **A resource neither side states matches**: means with no `serviceUrl`, or
90
+ one that does not parse (the CLI's XSUAA placeholder), and a session stored
91
+ without `issuedFor`; a resource stated on one side only never matches.
92
+ Without a strategy an absent resource still matches nothing.
93
+ - **The token API with a factory**: the destination must state `authType`
94
+ (`jwt` / `saml`) and `grantType` (else `DestinationConfigError` naming
95
+ both); `saml2_pure` and `none` get three arguments; every stored secret —
96
+ the refresh token in `authConfig` and the fourth argument, the token,
97
+ cookies and expiry in `connConfig` — reaches the factory only when the
98
+ session is bound here, and a result without a refresh token carries forward
99
+ only a bound one. (Without a strategy, 4.0.0's carry-over stands.) An
100
+ instance `provider` gets no strategy. What the strategy and the factory
101
+ are handed is built from allowlists, never a store's answer passed whole:
102
+ the client (`uaaUrl`, `uaaClientId`, `uaaClientSecret`), the certificate
103
+ client (its five declared fields), and the seed (`serviceUrl`, `sapClient`,
104
+ `language`, `authType`, `grantType`, and the session secret's fields only
105
+ from a bound read).
106
+
107
+ ### Dependencies
108
+
109
+ `@mcp-abap-adt/interfaces-auth-broker` `^1.2.0` (was `^1.1.0`),
110
+ `@mcp-abap-adt/interfaces-auth` `^3.2.0` (was `^3.0.0`),
111
+ `@mcp-abap-adt/auth-providers` `^5.3.0` (was `^5.2.1`); dev:
112
+ `@mcp-abap-adt/auth-stores` `^3.3.0`.
113
+
114
+ ### Migrating from 4.0.0
115
+
116
+ Nothing is required. Code that matched a `DestinationConfigError`'s message
117
+ for a destination without a client should match `missingFields` or the class
118
+ instead. To use an x509 key: auth-stores `^3.3.0` and
119
+ `clientAuthentication: fromServiceKeyCertificate()` — then read the strategy
120
+ path's rules above (identity binding, bound secrets only, the token API's
121
+ grant refusal, the factory's fourth argument). See *Migrating from 4.0.0* in
122
+ the README.
123
+
124
+ ### Measured, and not
125
+
126
+ Measured: `client_credentials` with an x509 XSUAA key on a BTP trial,
127
+ 2026-10-05 (`credential-types: ["binding-secret", "x509"]`) — the broker
128
+ (`getProvider` and the token API), `mcp-auth --client-auth certificate` and
129
+ `generate-env-from-service-key --client-auth certificate`, each followed by a
130
+ fresh broker over the written destination; all five live cases passed. Not
131
+ measured: `authorization_code` and `passcode` over x509, the OIDC grants and
132
+ `saml2_bearer` with a strategy, ABAP environment service keys with x509.
133
+
134
+ ### Known limitations
135
+
136
+ - **The certificate is pinned for the broker's lifetime.** It is read when a
137
+ destination's provider is built, and the provider is cached per
138
+ destination for as long as the `AuthBroker` lives. An XSUAA x509
139
+ certificate lives about seven days: a long-running process then gets "the
140
+ client certificate has expired" until it builds a new `AuthBroker`, and
141
+ replacing the key or the PEM files changes nothing before that. Rotation is
142
+ out of scope.
143
+ - The certificate refusals use the broker's own three phrases (incomplete /
144
+ expired / could not be used), classified by auth-providers'
145
+ `CertificateMaterialError` flags; the provider's own hint for each is not
146
+ relayed. auth-providers' exported refusal words (a later minor) would
147
+ replace them.
148
+
149
+ ## [4.0.0] - 2026-10-02
150
+
151
+ **What 4.0.0 is.** The broker hands a process **the credential for a
152
+ destination, ready to use**: `getProvider(destination)` returns the
153
+ `IAuthProvider` (`@mcp-abap-adt/interfaces-auth` 3) the destination states —
154
+ basic, SNC, a UAA, OIDC or SAML grant, or a credential handed over — which a
155
+ `@mcp-abap-adt/connection` 10 connector takes as it is, and whatever that
156
+ provider obtains or renews is stored in the session store, whoever triggered
157
+ it. The token API (`getToken`, `refreshToken`, `createTokenRefresher`) stays.
158
+ Released with `@mcp-abap-adt/auth-broker-cli` 2.0.0, the commands on this
159
+ version.
160
+
161
+ ### Breaking
162
+
163
+ 1. **The two stores have one role each.** The service key store holds the
164
+ *means* — `authType`, `grantType`, the client, basic's user and password,
165
+ the SNC, OIDC and SAML settings, `serviceUrl`; the session store holds the
166
+ *secret* — the token or cookies, `expiresAt`, the refresh token, and what
167
+ the secret is bound to (`issuedFor`, `issuedBy`). The broker writes the
168
+ secret alone, in one `saveSession`, and never a means field: no
169
+ `serviceUrl`, `authType` or client is written to the session any more
170
+ (3.x called `setConnectionConfig` and `setAuthorizationConfig`).
171
+ `getProvider` reads the means from the key store only: a destination
172
+ whose means only a session answers is a `DestinationConfigError`.
173
+ 2. **The session store must take a write of the secret alone:
174
+ `@mcp-abap-adt/auth-stores` 3 or later**, or a store of your own that does.
175
+ auth-stores 1.x and 2.x are not supported: their `AbapSessionStore`
176
+ throws on a `saveSession` without `serviceUrl` and their
177
+ `SafeAbapSessionStore` refuses it (measured on 1.2.3, 1.2.4 and 2.0.0), so
178
+ a token would be obtained and then never stored. auth-stores 3.1.0 or
179
+ later for the binding (item 4).
180
+ 3. **`getConnectionConfig` / `getAuthorizationConfig` compose the two
181
+ stores** — the key store's means with the session's secret laid over them
182
+ — instead of answering the session's configuration whole.
183
+ 4. **A stored secret is used only for the resource and issuer it was
184
+ obtained for** (`issuedFor`, `issuedBy`): one bound elsewhere, or not
185
+ bound at all, is not reused — the provider logs in afresh, and a `none`
186
+ destination is refused.
187
+ 5. **The token API refuses a destination the key store states as `basic` or
188
+ `snc`** (`DestinationConfigError`, `authType`), instead of writing a token
189
+ over its session.
190
+ 6. **Contracts and dependencies.** `@mcp-abap-adt/interfaces-auth` `^3.0.0`,
191
+ `@mcp-abap-adt/interfaces-auth-sap` `^2.0.0`,
192
+ `@mcp-abap-adt/interfaces-auth-broker` `^1.1.0` (the store contracts, new),
193
+ `@mcp-abap-adt/auth-providers` `^5.2.1` (now a runtime dependency). Types
194
+ of `interfaces-auth` 2.x and providers of auth-providers 4.x no longer mix
195
+ with this version.
196
+
197
+ ### Migrating from 3.x
198
+
199
+ - **Stores.** Use `@mcp-abap-adt/auth-stores` `^3.1.0` (this version is
200
+ tested against 3.2.0), or stores of your own on the
201
+ `@mcp-abap-adt/interfaces-auth-broker` contracts. State the means in a key
202
+ store: an `EnvDestinationStore(dir, { fallback })` over
203
+ `<destination>.env`, or `AbapServiceKeyStore(dir, { grantType })` /
204
+ `XsuaaServiceKeyStore(dir, { grantType })` over a SAP service key — a key
205
+ cannot state its grant, so whoever builds the store states it. The session
206
+ store keeps the secret: `AbapSessionStore`, `XsuaaSessionStore`,
207
+ `EnvFileSessionStore`, or the in-memory `Safe*` ones. A custom
208
+ `ISessionStore` must persist `issuedFor` and `issuedBy` beside the secret,
209
+ or the broker never reuses its sessions (a login every start).
210
+ - **3.x session files** hold means and secret together. Point an
211
+ `EnvDestinationStore` and a session store at the same directory: each reads
212
+ its own keys where they are. A `basic` or `snc` file works once it states
213
+ `SAP_AUTH_TYPE`; a `jwt` or `saml` file needs `SAP_GRANT_TYPE` (3.x never
214
+ wrote one) — run the `auth-broker-cli` command that produced it again, or
215
+ add the line. Its stored token is reused when its `SAP_URL` (+
216
+ `SAP_CLIENT`) and `SAP_UAA_URL` + `SAP_UAA_CLIENT_ID` are the destination's
217
+ (auth-stores 3.1.0 reads them as the binding); otherwise the first start
218
+ logs in once. XSUAA sessions written with an empty URL log in once.
219
+ - **A `@mcp-abap-adt/connection` 10 connector:** build it from
220
+ `await broker.getProvider(destination)` instead of a token and a token
221
+ refresher. Construct the broker with a `serviceKeyStore` and without a
222
+ `provider`, and give it the collaborators its destinations' grants use —
223
+ `authorization`, `oidcAuthorization`, `deviceCodePresenter`, `samlCookies`,
224
+ `assertionReplayStore`; there is no default. A headless process gives ones
225
+ that refuse. The connector's URL comes from your key store
226
+ (`getConnectionConfig`); `getProvider` does not need it.
227
+ - **The token API with a provider of your own** (calm-server's way): see
228
+ *The token API* below — the same calls, reads and answers; what it writes
229
+ is the secret alone.
230
+ - **`DestinationConfigError`** (new, exported): catch it where the process
231
+ builds its connector or asks for a token. `missingFields` names what the
232
+ destination lacks (a field, or a collaborator option) — never a value.
233
+ - **`flush()`** (new): a session write that fails is retried by the broker
234
+ instead of failing the authentication. Call `await broker.flush()` on
235
+ shutdown (`SIGTERM`, before a stdio transport closes) to learn whether
236
+ every secret is stored; it rejects naming the destinations still failing.
237
+ - **`bindingOf(means, client?)`** (new): a consumer that writes a handed-over
238
+ token or cookies (a `none` destination) to the session store itself writes
239
+ `bindingOf(means)` beside them, or `getProvider` refuses the destination.
240
+ - **Imports.** The re-exported types keep their names. If you import
241
+ `ISessionStore`, `IServiceKeyStore`, `IConnectionConfig` or `IConfig` from
242
+ `@mcp-abap-adt/interfaces-auth-sap` yourself, take them from
243
+ `@mcp-abap-adt/interfaces-auth-broker`; `IAuthorizationConfig` and
244
+ `AuthType` stay in `interfaces-auth-sap`. Providers are auth-providers 5:
245
+ the interactive ones take their `authorization` strategy explicitly.
246
+ - **The commands** are `@mcp-abap-adt/auth-broker-cli` (since 3.1.0); its
247
+ 2.0.0 is the version on this library — what changed for its flags, files
248
+ and exit codes is in its CHANGELOG, *Migrating from 1.0.0*.
249
+
250
+ #### The token API (`getToken`, `refreshToken`, `createTokenRefresher`)
251
+
252
+ **What stays.** A consumer that passes its own `provider` (an instance or a
253
+ `TokenProviderFactory`) calls the same methods with the same signatures and
254
+ gets the same answers: the provider is asked as before, seeded from the same
255
+ reads in the same order (the session first, then the key store), and its
256
+ errors — and a store's — reach the caller unchanged. `createTokenRefresher`
257
+ is unchanged and stays supported (calm-server injects it into its own
258
+ connection).
259
+
260
+ **What changes for it.**
261
+
262
+ 1. **The session gets the secret alone** — the token or cookies, `expiresAt`,
263
+ the refresh token, and what the secret is bound to (`issuedFor`,
264
+ `issuedBy`) — in one `saveSession`. No `serviceUrl`, `authType` or client
265
+ is written back any more, so a wrapper that only kept the written-back
266
+ `serviceUrl` out of the session (calm-server's `TargetUrlSessionStore`)
267
+ has nothing left to filter. A store that merges a write into what it holds
268
+ — auth-stores 3, and auth-stores 1.x/2.x's `XsuaaSessionStore` and
269
+ `EnvFileSessionStore` (measured on 1.2.4 and 2.0.0) — keeps what it holds.
270
+ 2. **The session store must accept a write of the secret alone.**
271
+ Measured: auth-stores **1.2.3, 1.2.4 and 2.0.0**'s `AbapSessionStore`
272
+ throws a `TypeError` on a `saveSession` without `serviceUrl`, and their
273
+ `SafeAbapSessionStore` refuses it (`missing required field: serviceUrl`) —
274
+ even when the session already holds its URL. On those stores `getToken`
275
+ obtains a token and then throws the store's error, and the broker's retry
276
+ cannot succeed. Use auth-stores **3** (`^3.1.0` for the binding) — or a
277
+ store of your own that takes the secret alone. (1.x/2.x's
278
+ `SafeXsuaaSessionStore` replaces the session with what is written: the
279
+ URL or client it held in memory is gone after the first token, so state
280
+ them in the key store.)
281
+ 3. **A destination the key store states as `basic` or `snc` is refused**
282
+ (`DestinationConfigError`, `missingFields: ['authType']`) instead of
283
+ getting a token written over its credential.
284
+ 4. **A failed write is retried.** It still reaches `getToken`'s caller; the
285
+ broker then retries it on its own, and `flush()` reports what is still
286
+ pending — call it on shutdown.
287
+
288
+ **Without a `provider`,** the token API serves what the destination states,
289
+ on the provider `getProvider` builds for it (see *Added*): the consumer
290
+ composes the key store (the means, `grantType` included) and the
291
+ collaborators instead of a provider.
292
+
293
+ ### Added
294
+
295
+ - **`bindingOf(means, client?)`** — the binding (`issuedFor`, `issuedBy`) the
296
+ broker writes beside a secret for a destination with those means, for a
297
+ consumer that hands over a credential (a `none` destination's token or
298
+ cookies) and writes it to the session store itself. It is the same function
299
+ `getProvider` checks a stored secret against and `persist` writes
300
+ (`destinationBinding`, `src/bindingOf.ts`), so what a consumer writes and
301
+ what the broker expects cannot diverge, and no consumer canonicalises a URI.
302
+ `@mcp-abap-adt/auth-broker-cli`'s `--cookie` uses it.
303
+
304
+ - **`getProvider(destination): Promise<IAuthProvider>`** — the credential the
305
+ destination states, for a `@mcp-abap-adt/connection` 10 connector. The
306
+ destination's `authType` (and `grantType`, for `jwt` and `saml`) chooses the
307
+ provider and nothing else does; the means are read from the service key
308
+ store only, the secret (token or cookies) from the session store only. Built
309
+ in this version: `basic` → `BasicAuthProvider`, `snc` →
310
+ `SncLogonProvider.forSecureLoginClient` (from `sncPartnerName`, `sncQop`,
311
+ `sncLib`, `sncMyName`), `jwt` / `none` → `TokenAuthProvider.fixed` with the
312
+ session's token, `saml` / `none` → `SamlAuthProvider` with the session's
313
+ cookies, the UAA grants, the OIDC grants and the SAML grants (below) —
314
+ every pair of the table. One provider per destination: the promise of the build is cached before the
315
+ first read, so concurrent first calls build once, and dropped when the build
316
+ throws, so the next call retries.
317
+ - **The UAA grants in `getProvider`**: `jwt` / `authorization_code` →
318
+ `AuthorizationCodeProvider`, `jwt` / `client_credentials` →
319
+ `ClientCredentialsProvider`, `jwt` / `passcode` → `UaaPasscodeProvider`. The
320
+ client (`uaaUrl`, `uaaClientId`, `uaaClientSecret`) comes from the key
321
+ store's `getAuthorizationConfig`; `authorization_code` and `passcode` are
322
+ seeded from the session — the token, the refresh token and `expiresAt` —
323
+ and without a session log in at `prepare()`; `client_credentials` takes the
324
+ client alone. `uaaClientSecret: ''` is a public client, which `passcode`
325
+ takes as no secret. No row needs `serviceUrl`: the resource URL is not
326
+ authorization data — no token provider reads it — so the connector takes it
327
+ from the key store, and `getProvider` neither requires it nor passes it to a
328
+ provider; it reads it only to bind a stored secret (below).
329
+ - **The OIDC grants in `getProvider`**: `jwt` / `oidc_authorization_code` →
330
+ `OidcBrowserProvider` (PKCE), `device_code` → `OidcDeviceFlowProvider`,
331
+ `password` → `OidcPasswordProvider`, `token_exchange` →
332
+ `OidcTokenExchangeProvider`. The client id and secret come from the key
333
+ store's client (`uaaClientSecret: ''` is a public client, sent with no
334
+ secret); the endpoints from `oidcIssuerUrl` (discovery) or, without it,
335
+ every explicit endpoint the row reads (`oidcAuthorizationEndpoint`,
336
+ `oidcDeviceAuthorizationEndpoint`, `oidcTokenEndpoint`); `oidcScopes` as
337
+ given, joined by one space into `token_exchange`'s `scope`; `password`'s
338
+ user and `token_exchange`'s subject, audience and actor token from the
339
+ means — sent, never written. Seeded from the session like the UAA rows;
340
+ `issuedBy` is `oidcIssuerUrl`, else `uaaUrl`, with `client_id`.
341
+ - **The SAML grants in `getProvider`**: `saml` / `saml2_pure` →
342
+ `Saml2PureProvider`, `saml2_bearer` → `Saml2BearerProvider`. The broker
343
+ composes the assertion validator from the destination's trust —
344
+ `createSignedResponseValidator` for `saml2_pure`,
345
+ `createSignedAssertionValidator` for `saml2_bearer`, from
346
+ `samlIdpCertificates`, `samlClockSkewMs` and the consumer's
347
+ `assertionReplayStore` — and passes `samlIdpEntityId` as the expected
348
+ issuer, with `samlIdpSsoUrl`, `samlSpEntityId`, `samlAcsUrl`,
349
+ `samlRelayState` and `samlIdpInitiated`. `saml2_pure` is seeded with the
350
+ stored cookies and `expiresAt` (auth-providers 5.1.0's seed) and bound to
351
+ `samlAcsUrl` (origin and path); `saml2_bearer` takes the client and
352
+ `samlTokenUrl`, is seeded with the token, refresh token and `expiresAt`, and
353
+ is bound to `uaaUrl` with `client_id`.
354
+ - **The `authorization` option is used**: `(destination, grant) =>
355
+ IAuthorizationStrategy<string>`, the interactive half of
356
+ `authorization_code` and `passcode` (for `passcode` the strategy is handed
357
+ `<uaaUrl>/passcode`). Called once per destination's build, never disposed
358
+ by the broker; required only by those two grants and the two SAML grants
359
+ (which it conducts with the grant passed), with no default.
360
+ - **The other collaborator options are used**: `oidcAuthorization` by
361
+ `oidc_authorization_code`, `deviceCodePresenter` by `device_code`,
362
+ `samlCookies` (the validated SAMLResponse → the system's session cookies)
363
+ by `saml2_pure`, `assertionReplayStore` by both SAML grants — each a
364
+ function of the destination, called once per build, never disposed, and
365
+ required only by its rows: missing, a `DestinationConfigError` naming it.
366
+ - **What a `getProvider` provider obtains is stored.** Every token provider
367
+ the broker builds gets `onTokens`, so a token obtained at `prepare()`, on
368
+ expiry, or in `rejected()` after a 401 is in the session store before the
369
+ provider answers. The write is the session secret alone — one
370
+ `saveSession(destination, { authorizationToken, expiresAt, refreshToken,
371
+ issuedFor, issuedBy })`, the stored refresh token carried forward when the
372
+ result has none and the stored session is bound where the new secret is —
373
+ and never a means field (no `serviceUrl`, no `authType`, no client secret,
374
+ no subject token, no password). A `saml2_pure` result (`tokenType:
375
+ 'saml'`) is written as cookies — `{ sessionCookies, expiresAt, issuedFor,
376
+ issuedBy }`, no refresh token; a `saml2_bearer` result as a token.
377
+ A destination the key store states as `basic` or `snc` is not written.
378
+ - **A stored secret is used only where it is bound** (`issuedFor` /
379
+ `issuedBy`, `@mcp-abap-adt/interfaces-auth-broker` 1.1.0). The broker
380
+ computes from the means the resource a secret is for (`serviceUrl` with
381
+ `sap-client`) and who issued it to which client (`uaaUrl` with
382
+ `client_id` for the UAA grants and `saml2_bearer`; the OIDC issuer, else
383
+ `uaaUrl`, with `client_id` for the OIDC grants; the ACS for `saml2_pure`),
384
+ canonicalises them and the session's values with one function
385
+ — scheme and host lower-cased, the port explicit (`443` / `80`), the path
386
+ without a trailing `/`, only `sap-client` (the means' `sapClient` first) or
387
+ `client_id` kept and re-encoded, so percent-encoding compares equal — and
388
+ writes both with every secret, each left out when the means lack its source.
389
+ The grants that obtain a secret (UAA, OIDC, SAML) are seeded only when both
390
+ stored values match; otherwise the
391
+ stored secret, refresh token included, is not used: the provider logs in
392
+ afresh and the log says only `<destination>: secret bound to another
393
+ resource, discarded`. A `none` destination is refused instead: a
394
+ `DestinationConfigError` naming `issuedFor` when the stored resource is not
395
+ the destination's or is absent, and naming `issuedBy` when the means state
396
+ an issuer (`oidcIssuerUrl` or the client for `jwt`, `samlAcsUrl` for `saml`)
397
+ and the stored one is not it. **What a consumer meets:** a custom
398
+ `ISessionStore` must persist both fields beside the secret, else every
399
+ process start is a fresh login; a headless process whose strategy refuses
400
+ logins answers Oops on a mismatch instead of presenting a foreign secret; a
401
+ changed URL, SAP client, UAA or client costs one login. Session files
402
+ written before `@mcp-abap-adt/auth-stores` 3.1.0 keep working: its stores
403
+ answer the binding from the file's `SAP_URL` (+ `SAP_CLIENT`) and
404
+ `SAP_UAA_URL` + `SAP_UAA_CLIENT_ID`; XSUAA sessions written with an empty
405
+ URL have none and log in once.
406
+ - **A failed session write does not fail the authentication**: it stays
407
+ pending for its destination and the broker retries it on its own — one
408
+ second, doubling, capped at one minute, on a timer that does not keep the
409
+ process alive. A newer result replaces the pending one; writes for one
410
+ destination never overlap; failures are logged by class name, never their
411
+ message.
412
+ - **`flush(): Promise<void>`** — one more attempt for every pending session
413
+ write; resolves when all have landed, rejects with an `AggregateError`
414
+ naming the destinations whose store still refuses (the retries go on). Call
415
+ it on shutdown.
416
+ - **`DestinationConfigError`** (`code: 'DESTINATION_CONFIG'`, `destination`,
417
+ `missingFields`) — a destination that lacks what its type needs:
418
+ no `serviceKeyStore` option, no means, no or an unknown `authType`, a
419
+ `jwt`/`saml` destination without a `grantType` or with a pair outside the
420
+ table, a missing field of its row (`''` counts as missing; for the UAA
421
+ grants `uaaUrl`, `uaaClientId`, and `uaaClientSecret` for
422
+ `authorization_code` / `client_credentials`), a missing collaborator option
423
+ (`authorization`; for the OIDC and SAML grants `oidcAuthorization`,
424
+ `deviceCodePresenter`, `samlCookies`, `assertionReplayStore`), an OIDC
425
+ grant with neither `oidcIssuerUrl` nor its endpoints (`oidcIssuerUrl` and
426
+ each endpoint missing), a SAML grant without its trust (`samlIdpSsoUrl`,
427
+ `samlSpEntityId`, `samlIdpEntityId`, `samlIdpCertificates` — an empty list
428
+ counts as missing), a certificate the validator cannot read
429
+ (`samlIdpCertificates`) or a `samlClockSkewMs` that is not a whole,
430
+ non-negative number, a `none` credential not bound to the destination
431
+ (`issuedFor`, `issuedBy`), or a provider
432
+ constructor's own `ValidationError` (an `sncQop` outside `1|2|3|8|9`, named
433
+ as the store field; the provider's error, which quotes the value, is not
434
+ kept). It names fields and options, never a stored value.
435
+ - **The token API shares `getProvider`'s provider.** Without a consumer
436
+ `provider`, `getToken`, `refreshToken` and `createTokenRefresher` ask the
437
+ very provider `getProvider` hands out for the destination — one per
438
+ destination, so one token, one refresh token and one renewal in flight
439
+ serve the connector and the token API alike (a `refreshToken()` during a
440
+ renewal in `rejected()` joins it). They write nothing themselves: the
441
+ provider's `onTokens` does, as for any `getProvider` provider. They serve the
442
+ destinations whose provider obtains a token — every grant but `none` — and
443
+ throw `DestinationConfigError` naming `provider` for a `none` destination,
444
+ before any provider is built. A failed write still reaches the caller:
445
+ `getToken` / `refreshToken` throw the store's error for the token they
446
+ received — as 3.x did — while the broker keeps retrying the write.
447
+ - The collaborator options `authorization`, `oidcAuthorization`,
448
+ `deviceCodePresenter`, `samlCookies`, `assertionReplayStore` on
449
+ `AuthBrokerConfig`, and the `StrategyGrant` type.
450
+ - **The broker's own test stand** (`tests/stand/`, `npm run test:stand`):
451
+ Cloud Foundry UAA and Keycloak in Docker, copied from auth-providers' stand,
452
+ with suites running the UAA grants end to end through auth-stores 3.1.0's
453
+ file stores — the binding asserted in the session file, and a session bound
454
+ to another URL not reused — the OIDC grants against Keycloak, and the SAML
455
+ grants with Keycloak as identity provider and UAA as service provider
456
+ (`saml2_pure`'s cookies a real UAA session from its web SSO ACS;
457
+ `saml2_bearer` exchanged at UAA's bearer grant); a CI workflow runs the build-and-test gate and the stand on every
458
+ push and pull request. Not shipped in the package.
459
+
460
+ ### Changed
461
+
462
+ - **`provider` is optional.** `getProvider` does not use it. With it, the
463
+ token API asks it, as in 3.x; without it, the token API asks
464
+ `getProvider`'s provider (above). With neither a `provider` nor a
465
+ `serviceKeyStore`, `getToken` and `refreshToken` throw
466
+ `DestinationConfigError` naming both.
467
+ - **Breaking: the token API refuses a destination stated `basic` or `snc`.**
468
+ It reads the destination's `authType` from the key store first and, for
469
+ `basic` or `snc`, throws `DestinationConfigError` naming `authType` before
470
+ any provider is asked — 3.x asked the provider and wrote `authType: 'jwt'`
471
+ and a token over the session of a destination that holds no token. A key
472
+ store that states no `authType` (a 3.x setup with a consumer `provider`),
473
+ or no key store at all, is served as before.
474
+ - **Breaking: the token API writes the session secret alone.** With a
475
+ consumer `provider` it still writes after every `getTokens()` /
476
+ `refreshTokens()`, cache hits included, but through the same write path as
477
+ `getProvider`'s providers: one `saveSession(destination, {
478
+ authorizationToken | sessionCookies, expiresAt, refreshToken, issuedFor,
479
+ issuedBy })`, retried when the store fails and awaited by `flush()`. 3.x
480
+ wrote the session's connection config back with `serviceUrl` and a derived
481
+ `authType` (`setConnectionConfig`), and a client the session held with the
482
+ new refresh token (`setAuthorizationConfig`); neither method is called any
483
+ more. A result without a refresh token carries the stored one forward.
484
+ `issuedFor` is the `serviceUrl` the token API resolved, with the SAP client
485
+ (the session's, else the key store's); `issuedBy` is the client a factory
486
+ was handed (`uaaUrl` with `client_id`) — an instance is handed no client,
487
+ so its tokens are bound to no issuer and `getProvider` never seeds from
488
+ them. Both are fixed when the provider is built for the destination (an
489
+ instance: at its first call for it) and kept with it, so a URL or client
490
+ changed later never re-labels a token obtained before the change; a new
491
+ broker picks the change up. A failed write is thrown to the caller as the store raised it, as in
492
+ 3.x, and retried.
493
+ - The token API's reads are 3.x's, and so are its answers: the session first,
494
+ then the key store, for `serviceUrl` and the client a factory is seeded
495
+ with; the factory called once per destination — now cached as the promise
496
+ of its build, so concurrent first calls build once — and again after one
497
+ that threw; an instance used for every destination; the provider's errors
498
+ and a store's failures propagate unchanged; a result without a token is an
499
+ error. `createTokenRefresher(destination)` is unchanged and not
500
+ deprecated.
501
+ - **Breaking: `getConnectionConfig` / `getAuthorizationConfig` compose the two
502
+ stores** instead of answering the session's configuration whole (and the
503
+ key's only when the session had none). `getConnectionConfig` is the key
504
+ store's means with the session's `authorizationToken`, `sessionCookies` and
505
+ `expiresAt` laid over them; `getAuthorizationConfig` is the key store's
506
+ client with the session's refresh token, `null` without a client in the key
507
+ store. Means a session store answers are not read, nor a secret a key store
508
+ answers. A consumer that kept the URL or the client only in its session
509
+ store gets them from a key store now.
510
+ - **Contracts from their 2026-10 packages.** `@mcp-abap-adt/interfaces-auth`
511
+ `^3.0.0` (was `^2.1.0`), `@mcp-abap-adt/interfaces-auth-sap` `^2.0.0` (was
512
+ `^1.0.1`), and `@mcp-abap-adt/interfaces-auth-broker` `^1.1.0` (new; 1.1.0
513
+ for `issuedFor` / `issuedBy`). The
514
+ store contracts — `IConfig`, `IConnectionConfig`, `ISessionStore`,
515
+ `IServiceKeyStore` — moved out of `interfaces-auth-sap` into
516
+ `interfaces-auth-broker`; this package now takes them from there.
517
+ `IAuthorizationConfig` and `AuthType` stay in `interfaces-auth-sap`. The
518
+ re-exported names are unchanged; only where they come from. A consumer that
519
+ imports them from this package changes nothing; one that imports
520
+ `ISessionStore` and friends from `interfaces-auth-sap` itself takes them
521
+ from `interfaces-auth-broker` from its 2.0.0 on.
522
+ - `@mcp-abap-adt/auth-providers` `^5.2.1` (was `^4.2.0`, a dev dependency) is
523
+ a runtime dependency: `getProvider` constructs its classes. None is
524
+ re-exported. 5.2.0 because `ClientCredentialsProvider` takes a `logger`
525
+ from it: the broker passes its own, so a `client_credentials` destination's
526
+ token lifecycle is logged like the other grants' (tokens redacted by the
527
+ provider); 5.2.1 because 5.2.0 skipped OIDC discovery unless every
528
+ device-flow endpoint was missing, so a `device_code` destination stating
529
+ its issuer and one explicit endpoint was refused at `prepare()`. The library's tests run against `@mcp-abap-adt/auth-stores`
530
+ `^3.2.0` (a dev dependency, was `^1.2.3`; the `auth-stores-3` alias the
531
+ stand, live and binding suites used is gone): its session stores hold the
532
+ secret alone, which is all the broker writes now. The carried-over on-disk
533
+ case reads the means from an `EnvDestinationStore` and asserts the session
534
+ file holds the secret and its binding and nothing else. Dev dependency
535
+ `@mcp-abap-adt/connection` `^10.0.3` (was `^10.0.2`), for the end-to-end
536
+ test: a connector built from `getProvider`, a 401 renewed in `rejected()`,
537
+ the new token in the session file, and the token API answering it.
538
+ - Comments no longer name `@mcp-abap-adt/auth-stores-btp` / `-xsuaa` (which do
539
+ not exist) or the deleted `@mcp-abap-adt/interfaces` facade.
540
+ - Dev dependency `@mcp-abap-adt/auth-mocks` `^0.3.0` (new): the SAML grants'
541
+ unit tests validate assertions its identity provider signs — one signing
542
+ the Response, one the Assertion alone — rather than documents the suite
543
+ signs itself. Not shipped.
544
+
14
545
  ## [3.1.0] - 2026-10-01
15
546
 
16
547
  **Why a minor.** The library's API is unchanged: every export, signature and