@mcp-abap-adt/auth-broker 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,7 +9,279 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  Thank you to all contributors! See [CONTRIBUTORS.md](../../CONTRIBUTORS.md) for the complete list.
11
11
 
12
- ## [Unreleased]
12
+ ## [5.0.0] - 2026-10-09
13
+
14
+ The broker on the auth chain's 6.0 contracts (auth-providers 6, interfaces-auth 7, auth-errors
15
+ 2). A major: what every failure is changes class, two options become required for token
16
+ destinations, the session binding gains a versioned record, and every wait can be cancelled.
17
+ Released with `@mcp-abap-adt/auth-broker-cli` 3.0.0. What a 4.x consumer must do, row by row:
18
+ the README's *Migrating to 5.0.0*.
19
+
20
+ ### Breaking
21
+
22
+ - **`renewal` is required for every token row.** `AuthBrokerConfig.renewal` — `(destination,
23
+ grant: TokenGrant) => IRenewalStrategy` — is called once per build of every UAA, OIDC and
24
+ SAML row, after every other check and before the provider's constructor; its answer is the
25
+ provider's `renewal`, unchanged. No default: a token row without it is a
26
+ `DestinationConfigError` naming `renewal`, beside every other missing field. A `renewal` that
27
+ throws is `DestinationConfigError(['renewal'])`, "the renewal option failed", carrying what it
28
+ threw. Not called for `basic`, `snc`, `none`, nor for the token API's `provider`.
29
+ `() => refreshThenLogin()` is 4.x's behaviour.
30
+ - **`onWriteFailure: 'fail' | 'continue'` is required** for every destination that writes a
31
+ secret — a token row `getProvider` builds, and every call of the token API with a `provider`.
32
+ Without it: `DestinationConfigError` naming `onWriteFailure`. `'fail'`: the call whose write
33
+ did not land fails (`unknown`, `persisting-tokens`), and while the destination's last write is
34
+ pending (failed, queued or in flight) `getProvider`, `getToken` and `refreshToken` retry it on
35
+ entry and once more before success, and are refused while it fails. `'continue'`: logged, the
36
+ call goes on. A moment of a provider already handed out that commits nothing is not refused.
37
+ - **Failures are `AuthProviderFailure`s, read through `@mcp-abap-adt/auth-errors`.** A
38
+ provider's failure reaches `getToken()` / `refreshToken()` as the same object. The broker reads
39
+ a caught value only through `readFailure` / `isAuthProviderFailure` — no `instanceof`,
40
+ `message` or `name` of an error is read in `src`. The broker's own failures are minted by
41
+ auth-errors: an aborted wait (`interactive-login`, `aborted`), a write that did not land under
42
+ `'fail'` (`unknown`, `persisting-tokens`, an allowlisted `code`), a provider that answered no
43
+ token (`request-failed`, `token-source`, `no-access-token` — replacing "Token provider did not
44
+ return authorization token …").
45
+ - **`DestinationConfigError` carries the provider's failure.** New `error?: IAuthProviderError`
46
+ (kind, facts, rendered words, admitted diagnostics; no `cause`) when a provider's or a
47
+ strategy's failure caused the refusal; the message then ends `: <error.reason>`. New
48
+ `isDestinationConfigError(value)` — structural, no `instanceof`; true for a JSON copy. A
49
+ provider constructor that throws inside a build — any row, not only SNC — is a
50
+ `DestinationConfigError` naming the store fields its configuration facts name. The SNC row
51
+ decides by kind (`configuration` → its fields), not by `instanceof ValidationError`. The
52
+ broker's own copy of the three certificate phrases is gone: `clientAuthentication`'s refusal
53
+ reads `the clientAuthentication strategy failed: <reason>` with auth-providers'
54
+ `client-certificate` failure carried. New rows: `renewal`, `onWriteFailure`, `provider` (an
55
+ instance after the destination's identity changed), a store field of a shape the broker
56
+ cannot take, `samlIdpInitiated`, `oidcScopes` and `oidcActorTokenType` of the wrong shape.
57
+ - **`issuedBy` is a versioned binding record**: `mcp-abap-adt-binding/2;<row>;<eleven address
58
+ fields>;<trust>` — the row (`authType/grantType`, or `provider/…` for the token API's consumer
59
+ provider), the exact string of every server address the row hands its provider
60
+ (`encodeURIComponent`-encoded, never canonicalised: `clientId`, `uaaUrl`, `oidcIssuerUrl`, the
61
+ three OIDC endpoints, `oidcAudience`, `samlIdpSsoUrl`, `samlAcsUrl`, `samlTokenUrl`, the
62
+ certificate client's `certUrl`) and the SHA-256 of the row's non-secret trust input (SAML
63
+ trust, OIDC scopes, the password grant's user, the token-exchange token types, the certificate
64
+ client's public certificate). Compared by exact equality, never parsed; no secret and no hash
65
+ of one in it. `issuedFor` keeps 4.x's canonical form. **Every session written before 5.0.0
66
+ reads as unbound**: its stored token and refresh token are not used, and the provider's first
67
+ renewal is a login (or a token request). A `none` row compares `issuedBy` always and refuses a
68
+ 4.x binding naming `issuedBy`. `bindingOf(means, client)` keeps its signature and returns the
69
+ version-2 record.
70
+ - **A stored secret seeds only a fully stated binding**: one whose record holds the client the
71
+ row authenticates and every address its provider sends a credential to — so an issuer-less
72
+ OIDC row with explicit endpoints and a client is now seeded, and `token_exchange` and the
73
+ token API's consumer provider never are.
74
+ - **A provider is never changed.** Every call of `getProvider` and the token API re-reads what
75
+ the destination's provider was built from (the means, the client, the certificate client when
76
+ read) and compares everything the build read, secrets included (in memory only); anything
77
+ changed builds a new provider that starts with nothing. 4.x cached a destination's provider
78
+ for the broker's life. An instance `provider` whose destination's identity changed is refused
79
+ until a new broker.
80
+ - **Two paths, resolved and cached apart.** The row path (`getProvider`, and the token API
81
+ without `provider`) and the consumer path (the token API with `provider`) each have their own
82
+ resolution, cache, identity and binding; only the destination's write queue is shared.
83
+ - **The token API's factory is handed no stored secret**: `authConfig` is the client
84
+ (`refreshToken` a key, never a value), `connConfig` the allowlisted connection means
85
+ (`serviceUrl`, `sapClient`, `language`, `authType`, `grantType`), the fourth argument the
86
+ strategy's answer and the client identity (`refreshToken` never set). The consumer path is
87
+ never seeded. A consumer provider's result without a refresh token writes `refreshToken: ''`
88
+ (the stored one is no longer carried).
89
+ - **Session writes state every field** (auth-stores 4 merges): a credential write states the
90
+ token or cookies, `expiresAt` (the provider's report), the refresh token the provider owns or
91
+ `''`, `issuedFor` (`''` when the means state no `serviceUrl`) and `issuedBy`; `saml2_pure`'s
92
+ cookies write `refreshToken: ''`; a refresh token discarded before any credential writes only
93
+ `refreshToken: ''`. A refresh token is written only by the provider that obtained it or was
94
+ seeded with it and has not discarded it since — never read from the store at write time.
95
+ - **The write queue has no retry timer.** One queue per destination: one write at a time, in
96
+ order; a replaced provider's late write is dropped once the new one has written; a failed
97
+ write stays pending until the destination's next write or `flush()`. The 1 s → 60 s retry
98
+ timer is gone. The store's contract, documented: `saveSession` settles.
99
+ - **`flush()` rejects with an `AggregateError` of `SessionWriteFailure`s** (`destination`,
100
+ `error`: the store's error classified `persisting-tokens`) instead of
101
+ `Error("<dest>": <class>)`; the message keeps 4.x's lead.
102
+ - **The built providers persist through auth-providers' `refreshStatePersistence`** (`onTokens`
103
+ is gone from every row), with the consumer's `onWriteFailure`.
104
+ - **`AuthBrokerConfig`'s optional fields are declared `?: T | undefined`.**
105
+
106
+ ### Added
107
+
108
+ - **Cancellation.** `BrokerCallOptions { signal? }` on `getProvider`, `getToken`,
109
+ `refreshToken`, `flush` and `createTokenRefresher`. Every wait is a waiter of auth-errors'
110
+ `sharedAttempt`: an abort releases that caller alone (`aborted`), the work runs on; when every
111
+ caller of a build aborted, the build is not cached and writes nothing. `getProvider`'s signal
112
+ is attached to the token or SNC provider it answers, so a login that provider starts later is
113
+ aborted once every holder's signal aborted; the token API never attaches and passes its signal
114
+ to `getTokens({ signal })` / `refreshTokens({ signal })`. `ClientAuthenticationContext.signal`:
115
+ the build's attempt. The broker sets no timeout and has no timer.
116
+ - **`authDebug?: boolean`** — passed to every token provider the broker builds, on only for
117
+ `true`, never read from the environment.
118
+ - Exports: `isDestinationConfigError`, `DestinationConfigErrorLike`, `SessionWriteFailure`,
119
+ `BrokerCallOptions`, `TokenGrant`, and the re-exported `IRenewalStrategy`.
120
+
121
+ ### Changed
122
+
123
+ - **Logging**: every line goes through a guard — a logger that throws or rejects changes no
124
+ outcome. A failed write is one `warn` line with auth-errors' `logFields`, never a class name or
125
+ message. A stored secret not used is a `warn` only where a stored secret could ever be seeded,
126
+ a `debug` line otherwise.
127
+ - **The certificate client is re-read on every call** when the build read it: a rotated
128
+ certificate builds a new provider at the next call (4.x pinned it for the broker's life).
129
+ - **Built under a stricter compiler.** Both packages, sources and tests, compile with
130
+ `noImplicitReturns`, `noFallthroughCasesInSwitch`, `noImplicitOverride`,
131
+ `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`; objects handed to a consumer keep
132
+ exactly their own keys (`src/contractShape.ts`). Lint: `noExplicitAny` is an error outside the
133
+ tests, and `lint:check` fails on any warning.
134
+ - **The broker stand and the live suites** run on the 6.0 chain and on
135
+ `@mcp-abap-adt/connection` 14; each states `renewal` and `onWriteFailure`.
136
+ - `tools/check-provider-shape.mjs`, copied byte for byte from auth-errors 2.1.1, runs with
137
+ rules 4, 5, 6 over both packages (`npm run check:shape`).
138
+
139
+ ### Dependencies
140
+
141
+ - `@mcp-abap-adt/auth-providers` `^5.3.0` → `^6.0.0`; `@mcp-abap-adt/interfaces-auth` `^3.2.0`
142
+ → `^7.5.0`; `@mcp-abap-adt/interfaces-auth-sap` `^2.0.0` → `^3.3.0`;
143
+ `@mcp-abap-adt/interfaces-auth-broker` `^1.2.0` → `^1.3.0`; new `@mcp-abap-adt/auth-errors`
144
+ `^2.1.1`. Dev: `@mcp-abap-adt/auth-stores` `^4.0.0`, `@mcp-abap-adt/connection` `^14.0.0`.
145
+
146
+ ### Measured, and not
147
+
148
+ - Not yet re-run on 5.0.0: the x509 live check (`npm run test:live:x509`, last run 2026-10-05 on
149
+ 4.1.0) and the live suite (`npm run test:live`).
150
+
151
+ ## [4.1.0] - 2026-10-05
152
+
153
+ **x509 service keys.** A client that authenticates to the authorization
154
+ server with a certificate instead of a secret — an XSUAA service key created
155
+ with `{"credential-type": "x509"}` — when the consumer says so, through a
156
+ strategy. Additive: without the new option every destination is served
157
+ exactly as by 4.0.0, and nothing certificate-related is read. Released with
158
+ `@mcp-abap-adt/auth-broker-cli` 2.1.0.
159
+
160
+ ### Added
161
+
162
+ - **`AuthBrokerConfig.clientAuthentication`** — a `ClientAuthenticationStrategy`
163
+ `(context) => Promise<IClientAuthentication>`: how the client of every grant
164
+ that authenticates one (`authorization_code`, `client_credentials`,
165
+ `passcode`, `oidc_authorization_code`, `device_code`, `password`,
166
+ `token_exchange`, `saml2_bearer` — `ClientAuthenticationGrant`)
167
+ authenticates. Called once per build with a `ClientAuthenticationContext`
168
+ (`destination`, `grant`, the key store's secret `client` — `uaaUrl`,
169
+ `uaaClientId`, `uaaClientSecret` only, never a refresh token — or `null`, and a
170
+ lazy, memoised `readCertificate()`); its answer goes to the provider as
171
+ `clientAuthentication`, with no client secret. No default and no "nothing"
172
+ answer: a stated choice never falls back to the secret.
173
+ - **`fromServiceKeyCertificate()`** — auth-providers' `tlsClientCertificate`
174
+ from the key store's certificate client, against `<certUrl>/oauth/token`
175
+ (a trailing `/` of `certUrl` dropped), the material checked before it
176
+ answers. **`fromServiceKeySecret({ encoding: 'raw' | 'form' })`** —
177
+ `clientSecretBasic` with the secret client; `encoding` required (`raw` for
178
+ XSUAA, measured). Each fails closed; a fallback is the consumer's
179
+ composition, decided by what the key holds (`readCertificate()`), never by
180
+ catching a refusal.
181
+ - **The certificate client** is read through the optional
182
+ `IServiceKeyStore.getClientCertificate` (`@mcp-abap-adt/interfaces-auth-broker`
183
+ 1.2.0) — auth-stores 3.3.0 answers it for an x509 XSUAA key and for an
184
+ `EnvDestinationStore` stating `SAP_UAA_CLIENT_CERT_PATH`,
185
+ `SAP_UAA_CLIENT_KEY_PATH` and `SAP_UAA_CERT_URL` (`XSUAA_UAA_*`) — only
186
+ when the strategy asks, at most once per build. The row's client is then
187
+ its identity: the secret client's `uaaUrl` / `uaaClientId`, else the
188
+ certificate client's.
189
+ - **The token API factory's fourth argument, `TokenProviderClient`** —
190
+ beside a strategy, for a grant that authenticates a client: the strategy's
191
+ `clientAuthentication`, the client identity (`uaaUrl`, `clientId`) and the
192
+ stored `refreshToken` when the session is bound here — taken only from the
193
+ session read whose binding was checked, never from a client's own read.
194
+ Never a certificate, key or secret. Additive: a 4.0.0 factory keeps working.
195
+ - **Fixed-words refusals**: whatever a strategy, a store it reads or the
196
+ certificate check throws — and an answer that is no `IClientAuthentication`
197
+ — is a `DestinationConfigError` naming `clientAuthentication` before any
198
+ provider exists, its words chosen by the error's class (no certificate, no
199
+ secret, incomplete / expired / unusable certificate, no client
200
+ authentication, the certificate could not be read, the strategy failed); a
201
+ factory that throws beside a strategy is one naming `provider`,
202
+ `clientAuthentication`. No `cause`, nothing of the thrown message, no PEM
203
+ anywhere — log, refusal, error, session store.
204
+ - Exports: `fromServiceKeyCertificate`, `fromServiceKeySecret`, and the
205
+ types `ClientAuthenticationStrategy`, `ClientAuthenticationContext`,
206
+ `ClientAuthenticationGrant`, `FromServiceKeySecretOptions`,
207
+ `TokenProviderClient`, `IClientCertificate`, `IClientAuthentication`.
208
+ - `npm run test:live:x509`: the live check against a BTP subaccount (exact
209
+ `cf target` guard, a GUID ledger, teardown also on failure). Not in CI.
210
+
211
+ ### Changed
212
+
213
+ - **A client row with no client and no strategy names the way out.** The
214
+ `DestinationConfigError` of a UAA, OIDC or `saml2_bearer` destination whose
215
+ key store answers no client (or one without a client id) keeps its
216
+ `missingFields`; its message adds `; a certificate client needs a
217
+ clientAuthentication strategy`.
218
+
219
+ ### On the strategy path (only with `clientAuthentication`)
220
+
221
+ - **The binding is the client's identity**: `issuedBy` from the identity's
222
+ `uaaUrl` and `client_id`, so a certificate destination's tokens are reused
223
+ after the broker is recreated and dropped when the issuer or client id
224
+ changes — and a secret key and an x509 key of the same client id share one
225
+ session (switching keeps its token and refresh token).
226
+ - **A resource neither side states matches**: means with no `serviceUrl`, or
227
+ one that does not parse (the CLI's XSUAA placeholder), and a session stored
228
+ without `issuedFor`; a resource stated on one side only never matches.
229
+ Without a strategy an absent resource still matches nothing.
230
+ - **The token API with a factory**: the destination must state `authType`
231
+ (`jwt` / `saml`) and `grantType` (else `DestinationConfigError` naming
232
+ both); `saml2_pure` and `none` get three arguments; every stored secret —
233
+ the refresh token in `authConfig` and the fourth argument, the token,
234
+ cookies and expiry in `connConfig` — reaches the factory only when the
235
+ session is bound here, and a result without a refresh token carries forward
236
+ only a bound one. (Without a strategy, 4.0.0's carry-over stands.) An
237
+ instance `provider` gets no strategy. What the strategy and the factory
238
+ are handed is built from allowlists, never a store's answer passed whole:
239
+ the client (`uaaUrl`, `uaaClientId`, `uaaClientSecret`), the certificate
240
+ client (its five declared fields), and the seed (`serviceUrl`, `sapClient`,
241
+ `language`, `authType`, `grantType`, and the session secret's fields only
242
+ from a bound read).
243
+
244
+ ### Dependencies
245
+
246
+ `@mcp-abap-adt/interfaces-auth-broker` `^1.2.0` (was `^1.1.0`),
247
+ `@mcp-abap-adt/interfaces-auth` `^3.2.0` (was `^3.0.0`),
248
+ `@mcp-abap-adt/auth-providers` `^5.3.0` (was `^5.2.1`); dev:
249
+ `@mcp-abap-adt/auth-stores` `^3.3.0`.
250
+
251
+ ### Migrating from 4.0.0
252
+
253
+ Nothing is required. Code that matched a `DestinationConfigError`'s message
254
+ for a destination without a client should match `missingFields` or the class
255
+ instead. To use an x509 key: auth-stores `^3.3.0` and
256
+ `clientAuthentication: fromServiceKeyCertificate()` — then read the strategy
257
+ path's rules above (identity binding, bound secrets only, the token API's
258
+ grant refusal, the factory's fourth argument). See *Migrating from 4.0.0* in
259
+ the README.
260
+
261
+ ### Measured, and not
262
+
263
+ Measured: `client_credentials` with an x509 XSUAA key on a BTP trial,
264
+ 2026-10-05 (`credential-types: ["binding-secret", "x509"]`) — the broker
265
+ (`getProvider` and the token API), `mcp-auth --client-auth certificate` and
266
+ `generate-env-from-service-key --client-auth certificate`, each followed by a
267
+ fresh broker over the written destination; all five live cases passed. Not
268
+ measured: `authorization_code` and `passcode` over x509, the OIDC grants and
269
+ `saml2_bearer` with a strategy, ABAP environment service keys with x509.
270
+
271
+ ### Known limitations
272
+
273
+ - **The certificate is pinned for the broker's lifetime.** It is read when a
274
+ destination's provider is built, and the provider is cached per
275
+ destination for as long as the `AuthBroker` lives. An XSUAA x509
276
+ certificate lives about seven days: a long-running process then gets "the
277
+ client certificate has expired" until it builds a new `AuthBroker`, and
278
+ replacing the key or the PEM files changes nothing before that. Rotation is
279
+ out of scope.
280
+ - The certificate refusals use the broker's own three phrases (incomplete /
281
+ expired / could not be used), classified by auth-providers'
282
+ `CertificateMaterialError` flags; the provider's own hint for each is not
283
+ relayed. auth-providers' exported refusal words (a later minor) would
284
+ replace them.
13
285
 
14
286
  ## [4.0.0] - 2026-10-02
15
287