@mcp-abap-adt/auth-broker 4.1.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/README.md CHANGED
@@ -4,300 +4,222 @@
4
4
  A per-destination credential broker for SAP BTP and ABAP systems. For a destination — a name,
5
5
  such as `TRIAL` — `getProvider` builds the `IAuthProvider` the destination states (basic, SNC, a
6
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
7
+ and the *secret* in the session store, ready for a `@mcp-abap-adt/connection` 14 connector, and
8
8
  stores back every token or set of SAML session cookies that provider obtains or renews. The
9
9
  token API (`getToken`, `refreshToken`, `createTokenRefresher`) serves whoever wants a token and
10
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. **On 4.0.0?** 4.1.0 is additive — a client that
17
- authenticates with an x509 certificate, through the `clientAuthentication` strategy (see
18
- [*How the Client Authenticates*](#how-the-client-authenticates-clientauthentication)); what a
19
- 4.0.0 consumer may notice is in [*Migrating from 4.0.0*](#migrating-from-400).
20
-
21
- The `mcp-auth` and `mcp-sso` commands that write destination files are in
22
- [`@mcp-abap-adt/auth-broker-cli`](../auth-broker-cli/README.md) (2.1.0, on this
23
- version), in the same repository. Up to 3.0.4 they shipped in this package; from 3.1.0 this package
24
- has no `bin`. Install the commands with
25
- `npm i -g @mcp-abap-adt/auth-broker-cli` — after
26
- `npm uninstall -g @mcp-abap-adt/auth-broker` if you had installed this one
27
- globally for them.
11
+ when to refresh and when to log in is the provider's call (`@mcp-abap-adt/auth-providers`) and
12
+ the renewal strategy's you give it; where means and secrets live is the stores'
13
+ (`@mcp-abap-adt/auth-stores`, or your own).
14
+
15
+ **Upgrading from 4.x?** 5.0.0 is a major: see [*Migrating to 5.0.0*](#migrating-to-500). In
16
+ short — a token destination needs two options the broker has no default for (`renewal`,
17
+ `onWriteFailure`); every failure is an `AuthProviderFailure` read through
18
+ `@mcp-abap-adt/auth-errors`; every session written before 5.0.0 reads as unbound once, so each
19
+ token destination logs in once after the upgrade.
20
+
21
+ The `mcp-auth` command that writes destination files is
22
+ [`@mcp-abap-adt/auth-broker-cli`](../auth-broker-cli/README.md) (3.0.0, on this version), in the
23
+ same repository. This package has no `bin`.
28
24
 
29
25
  ## Features
30
26
 
31
27
  - 🔌 **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
32
- - 💾 **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
33
- - 🔒 **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
34
- - 🎯 **Per destination**: one provider per destination name, shared by `getProvider` and the token API — one token, one refresh token, one renewal in flight
35
- - 🔄 **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
36
- - 🪙 **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
37
- - ⚡ **Forced refresh**: `refreshToken()` obtains a new token even when the cached one looks valid — for a caller holding a 401
38
- - 🧾 **JWT or SAML cookies**: what the provider returns is saved as a token or as session cookies
39
- - 🔑 **No secrets copied**: The client secret stays in the service key; the session store gets tokens only
40
- - 📜 **x509 service keys**: a client that authenticates with a certificate instead of a secret (`tls_client_auth`), when you say so — `clientAuthentication: fromServiceKeyCertificate()`; the certificate and key are read from the key store, and never reach the session store, a log line or an error
28
+ - 🧭 **You compose, the broker does not guess**: how a token provider renews (`renewal`) and what a session write that did not land means (`onWriteFailure`) are your statements, with no default
29
+ - 💾 **What a provider obtains is stored**: every token or set of SAML session cookies a provider obtains or renews — at `prepare()`, on expiry, or after a 401 in `rejected()` — is written to the session store before the provider answers, one write at a time per destination; `flush()` tells you whether everything landed
30
+ - 🔒 **A credential stays bound to its identity**: a stored secret is reused only by a provider built from exactly the means it was obtained under — the resource, the row (`authType` / `grantType`), the client, every server address and the trust; anything else changed, a new provider is built, and it starts with nothing
31
+ - 🛑 **Every wait can be cancelled by whoever waits**: `getProvider`, the token API and `flush()` take a `signal`; the broker sets no timeout of its own
32
+ - 🧾 **Failures as the provider made them**: a provider's `AuthProviderFailure` reaches you as the same object; the broker's own refusals carry names, never values
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
34
+ - 📜 **x509 service keys**: a client that authenticates with a certificate instead of a secret, when you say so — `clientAuthentication: fromServiceKeyCertificate()`; the certificate and key never reach the session store, a log line or an error
41
35
 
42
36
  ## Installation
43
37
 
44
38
  ```bash
45
- npm install @mcp-abap-adt/auth-broker
39
+ npm install @mcp-abap-adt/auth-broker @mcp-abap-adt/auth-providers @mcp-abap-adt/auth-errors @mcp-abap-adt/auth-stores
46
40
  ```
47
41
 
48
- Requires Node.js 22, 24 or 26 (`engines: "^22 || ^24 || ^26"`): 22 and 24 are
49
- the versions SAP BTP's Cloud Foundry Node.js buildpack offers, and 26 is
50
- supported as well.
42
+ 5.0.0 depends on `@mcp-abap-adt/auth-providers` `^6.0.0`, `@mcp-abap-adt/auth-errors`
43
+ `^2.1.1`, `@mcp-abap-adt/interfaces-auth` `^7.5.0`, `@mcp-abap-adt/interfaces-auth-sap`
44
+ `^3.3.0`, `@mcp-abap-adt/interfaces-auth-broker` `^1.3.0` and
45
+ `@mcp-abap-adt/interfaces-utils` `^1.1.0`. Declare `auth-providers` yourself — the renewal
46
+ strategies (`refreshThenLogin`, `refreshOnly`) and the interactive strategies come from it —
47
+ and `auth-errors` to read a failure. The stores are yours to choose: `@mcp-abap-adt/auth-stores`
48
+ `^4.0.0`, or any implementation of the `@mcp-abap-adt/interfaces-auth-broker` contracts. Keep
49
+ one installed copy of each contract package (`npm ls @mcp-abap-adt/interfaces-auth`,
50
+ `npm ls @mcp-abap-adt/auth-errors`): a failure of another copy is still read correctly, but
51
+ loses its diagnostics.
51
52
 
52
- ## Usage
53
+ Requires Node.js 22, 24 or 26 (`engines: "^22 || ^24 || ^26"`): 22 and 24 are the versions
54
+ SAP BTP's Cloud Foundry Node.js buildpack offers, and 26 is supported as well.
53
55
 
54
- ### Basic Usage
56
+ ## Usage
55
57
 
56
- This is the token API with a provider of your own — the 3.x way, which keeps
57
- working. The broker takes a session store, an optional service key store, and
58
- a token provider implementing `IRefreshableTokenProvider` (from
59
- `@mcp-abap-adt/interfaces-auth`) — or a factory that builds one per
60
- destination. Without a `provider` the token API serves what the destination
61
- states, on the provider `getProvider` builds (see *Getting Tokens*); for a
62
- `@mcp-abap-adt/connection` 10 connector, use `getProvider` itself.
58
+ ### A Provider for a Connector: `getProvider`
63
59
 
64
60
  ```typescript
65
61
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
62
+ import {
63
+ browserCallbackStrategy,
64
+ linuxDefaultBrowser,
65
+ refreshThenLogin,
66
+ } from '@mcp-abap-adt/auth-providers';
66
67
  import {
67
68
  AbapServiceKeyStore,
68
69
  AbapSessionStore,
70
+ EnvDestinationStore,
69
71
  } from '@mcp-abap-adt/auth-stores';
70
- import {
71
- AuthorizationCodeProvider,
72
- browserCallbackStrategy,
73
- } from '@mcp-abap-adt/auth-providers';
74
72
 
75
73
  const broker = new AuthBroker(
76
74
  {
75
+ // The means: <destinations>/<name>.env, else the SAP service key <keys>/<name>.json.
76
+ serviceKeyStore: new EnvDestinationStore('/path/to/destinations', {
77
+ fallback: new AbapServiceKeyStore('/path/to/keys', {
78
+ grantType: 'authorization_code',
79
+ }),
80
+ }),
81
+ // The secret: the token or cookies, its expiry, the refresh token, its binding.
77
82
  sessionStore: new AbapSessionStore('/path/to/sessions'),
78
- serviceKeyStore: new AbapServiceKeyStore('/path/to/keys'), // optional
79
- // Called once per destination, seeded with what the stores hold.
80
- provider: (destination, authConfig, connConfig) => {
81
- if (!authConfig) throw new Error(`No UAA credentials for ${destination}`);
82
- return new AuthorizationCodeProvider({
83
- uaaUrl: authConfig.uaaUrl,
84
- clientId: authConfig.uaaClientId,
85
- clientSecret: authConfig.uaaClientSecret,
86
- refreshToken: authConfig.refreshToken, // stored by an earlier login
87
- accessToken: connConfig.authorizationToken, // reused while valid
88
- authorization: browserCallbackStrategy({ browser: 'system' }),
89
- });
90
- },
83
+ // How a token provider renews: refresh, then log in (4.x's steps). Required.
84
+ renewal: () => refreshThenLogin(),
85
+ // What a session write that did not land means. Required.
86
+ onWriteFailure: 'fail',
87
+ // The interactive half of authorization_code: the platform's default browser.
88
+ authorization: () => browserCallbackStrategy({ browser: linuxDefaultBrowser() }),
91
89
  },
92
90
  logger, // optional ILogger
93
91
  );
94
92
 
95
- const token = await broker.getToken('TRIAL');
96
- ```
97
-
98
- The factory receives:
99
-
100
- - `authConfig` — the UAA credentials: the session's own when it holds them,
101
- else the service key's; carrying the refresh token the session stored.
102
- `null` when no store has credentials (a SAML flow needs none).
103
- - `connConfig` — the session's connection config, with `serviceUrl` resolved
104
- (from the session, else the service key) and the token stored last.
105
-
106
- These are 3.x's reads, in 3.x's order, kept for this path: a session store
107
- that still answers a client or a URL is read first. auth-stores 3's session
108
- stores answer neither, so the key store's are what is found. What the broker
109
- writes back is the session secret alone (see *Getting Tokens*).
110
-
111
- A provider instance can be passed instead of a factory; it is then used as
112
- given, for every destination, and the broker seeds it with nothing:
113
-
114
- ```typescript
115
- const broker = new AuthBroker({
116
- sessionStore: new AbapSessionStore('/path/to/sessions'),
117
- provider: new AuthorizationCodeProvider({
118
- uaaUrl, clientId, clientSecret,
119
- authorization: browserCallbackStrategy({ browser: 'system' }),
120
- }),
121
- });
93
+ const session = new AbortController(); // the connector's session: abort it when the session ends
94
+ const provider = await broker.getProvider('TRIAL', { signal: session.signal });
95
+ // new AdtCloudConnector({ url, client, authType: 'jwt' }, provider, transport, logger) …
122
96
  ```
123
97
 
124
- > `AuthorizationCodeProvider` and the other `@mcp-abap-adt/auth-providers` 5
125
- > providers implement `IRefreshableTokenProvider` (from `@mcp-abap-adt/interfaces-auth`
126
- > 3). Since auth-providers 5 the interactive ones take their `authorization`
127
- > strategy explicitly — none is built for you.
98
+ `getProvider(destination)` returns the `IAuthProvider` (from `@mcp-abap-adt/interfaces-auth`
99
+ 7) that a `@mcp-abap-adt/connection` 14 connector takes as it is. The broker reads two stores,
100
+ each for one role:
128
101
 
129
- ### A Provider for a Connector: `getProvider`
102
+ - **the means** — `authType`, `grantType`, basic's user and password, the SNC, OIDC and SAML
103
+ fields, `serviceUrl`, the client — from the **service key store** (`getConnectionConfig`,
104
+ `getAuthorizationConfig`, and `getClientCertificate` when a `clientAuthentication` strategy
105
+ asks), and only from there;
106
+ - **the secret** — the token or session cookies, `expiresAt`, the refresh token, and what it
107
+ is bound to — from the **session store** (`loadSession`), and only from there.
130
108
 
131
- `getProvider(destination)` returns the `IAuthProvider` (from
132
- `@mcp-abap-adt/interfaces-auth` 3) that a `@mcp-abap-adt/connection` 10
133
- connector takes as it is. The destination states which provider it gets; the
134
- broker reads two stores, each for one role:
135
-
136
- - **the means** — `authType`, `grantType`, basic's user and password, the SNC,
137
- OIDC and SAML fields, `serviceUrl`, the client — from the **service key store**
138
- (`getConnectionConfig`), and only from there;
139
- - **the secret** — the token or session cookies, `expiresAt`, the refresh
140
- token — from the **session store** (`loadSession`), and only from there.
141
-
142
- Means a session store happens to answer are not read, and neither is a token
143
- or cookies a key store happens to answer. Nothing is inferred: the
144
- destination's `authType` (and `grantType`, for `jwt` and `saml`) decides the
145
- provider, never which other fields are present.
146
-
147
- ```typescript
148
- import { AuthBroker } from '@mcp-abap-adt/auth-broker';
149
-
150
- const broker = new AuthBroker({
151
- serviceKeyStore: myKeyStore, // the means — required by getProvider
152
- sessionStore: mySessionStore, // the secret
153
- // no `provider`: that option is the token API's source, not getProvider's
154
- });
109
+ Nothing is inferred: the destination's `authType` (and `grantType`, for `jwt` and `saml`)
110
+ decides the provider, never which other fields are present.
155
111
 
156
- const provider = await broker.getProvider('DEV'); // an IAuthProvider
157
- // const connection = createAbapConnection({ url, provider }, logger) …
158
- ```
159
-
160
- | `authType` / `grantType` | Provider (auth-providers 5.3) | Read from the key store | Read from the session store |
112
+ | `authType` / `grantType` | Provider (auth-providers 6) | Read from the key store | Read from the session store |
161
113
  |---|---|---|---|
162
114
  | `basic` (no grant read) | `new BasicAuthProvider(username, password)` | `username`, `password` | nothing |
163
115
  | `snc` (no grant read) | `SncLogonProvider.forSecureLoginClient({ partnerName, qop, sncLib, myName, logger })` | `sncPartnerName` (required); `sncQop`, `sncLib`, `sncMyName` when set | nothing |
164
- | `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 |
116
+ | `jwt` / `authorization_code` | `AuthorizationCodeProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret`; `serviceUrl`, `sapClient` for the binding | the seed: `authorizationToken`, `refreshToken`, `expiresAt` — see *A Credential Stays Bound to Its Identity* |
165
117
  | `jwt` / `client_credentials` | `ClientCredentialsProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret`; `serviceUrl`, `sapClient` for the binding | nothing (the row takes the client alone) |
166
- | `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 |
167
- | `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 |
118
+ | `jwt` / `passcode` | `UaaPasscodeProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret` (`''` = a public client); `serviceUrl`, `sapClient` | the seed, as above |
119
+ | `jwt` / `oidc_authorization_code` | `OidcBrowserProvider` (PKCE) | `uaaClientId`, `uaaClientSecret` (`''` = a public client); `oidcIssuerUrl`, or `oidcAuthorizationEndpoint` + `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` | the seed, as above |
168
120
  | `jwt` / `device_code` | `OidcDeviceFlowProvider` | the client as above; `oidcIssuerUrl`, or `oidcDeviceAuthorizationEndpoint` + `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` | the seed, as above |
169
121
  | `jwt` / `password` | `OidcPasswordProvider` | the client as above; `username`, `password`; `oidcIssuerUrl`, or `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` | the seed, as above |
170
- | `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) |
171
- | `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 |
172
- | `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 |
173
- | `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 |
174
- | `saml` / `none` | `new SamlAuthProvider(sessionCookies)` | `authType`, `grantType`, `serviceUrl` (+ `sapClient`); `samlAcsUrl` when stated | `sessionCookies` (required), `issuedFor` (required to match); `issuedBy` when `samlAcsUrl` is stated |
175
-
176
- **No provider reads `serviceUrl`.** The URL of the system is where the
177
- connector connects, not authorization data: no token provider reads it — a
178
- UAA grant needs the client and `uaaUrl` — so `getProvider` neither requires it
179
- nor passes it to a provider. Give the connector its URL from your key store
180
- (`getConnectionConfig`), as the key store answers it. The broker reads it,
181
- with `sapClient`, for one thing only: to bind a stored secret to the resource
182
- it was obtained for (see *A Secret Is Bound to Its Resource and Issuer*). A
183
- token destination without it still gets its provider, but no stored secret
184
- is reused for it.
185
-
186
- `none` is how a handed-over credential is stated: the key store says
187
- `grantType: 'none'`, and the token or cookies live in the session. The SNC
188
- row takes the contract's own defaults when a field is absent — the library is
189
- discovered (`SNC_LIB_64`, `SNC_LIB`, the Secure Login Client's install path)
190
- when `sncLib` is, the user's SNC name comes from the credential when
191
- `sncMyName` is, and `qop` is the provider's `'9'` when `sncQop` is.
192
-
193
- The allowed pairs are `jwt` with `authorization_code`, `client_credentials`,
194
- `passcode`, `oidc_authorization_code`, `device_code`, `password`,
195
- `token_exchange` or `none`, and `saml` with `saml2_pure`, `saml2_bearer` or
196
- `none`. A pair outside them is a `DestinationConfigError` naming `grantType`.
197
-
198
- **The UAA grants.** The client comes from the key store's
199
- `getAuthorizationConfig` — never from the session store. A session is the
200
- seed: the stored token is presented while it is valid (a JWT's own `exp`
201
- decides; the stored `expiresAt` serves a token that carries none), and the
202
- stored refresh token renews it. Without a session the provider is built
203
- unseeded and obtains its first token at `prepare()`. `uaaClientSecret: ''` is
204
- a public client: `passcode` takes it as no secret; `AuthorizationCodeProvider`
205
- and `ClientCredentialsProvider` require a secret, so for them `''` is missing.
206
- With a `clientAuthentication` strategy no secret is read or required: the
207
- client authenticates with the strategy's answer — a certificate, say (see *How
208
- the Client Authenticates*).
209
-
210
- **The `authorization` option** is the interactive half of
211
- `authorization_code` and `passcode`: a function of the destination and the
212
- grant, returning the `IAuthorizationStrategy<string>` the provider logs in
213
- with. The broker calls it once, when it builds that destination's provider,
214
- and never disposes what it returns — whoever constructs, disposes. For
215
- `passcode` the strategy is handed `<uaaUrl>/passcode` as the URL to send the
216
- user to, and returns the code. There is no default: a destination whose grant
217
- needs it, without it, is a `DestinationConfigError` naming `authorization`.
218
- A headless process passes one that refuses (see *Headless Processes*).
219
-
220
- ```typescript
221
- import { browserCallbackStrategy, manualPasscodeStrategy } from '@mcp-abap-adt/auth-providers';
222
-
223
- const broker = new AuthBroker({
224
- serviceKeyStore: myKeyStore,
225
- sessionStore: mySessionStore,
226
- authorization: (destination, grant) =>
227
- grant === 'passcode'
228
- ? manualPasscodeStrategy({ timeoutMs: 300_000 })
229
- : browserCallbackStrategy({ timeoutMs: 120_000 }),
230
- });
231
- ```
232
-
233
- **The OIDC grants.** The client is the key store's `getAuthorizationConfig`
234
- again: `uaaClientId`, and `uaaClientSecret` — `''` is a public client, sent
235
- with no secret. (A key store answers a client when it holds one: from
236
- auth-stores 3.2.0, `EnvDestinationStore` answers one whenever `uaaClientId`
237
- is stated, a missing `uaaUrl` or secret as `''` — so an OIDC destination
238
- states its issuer in `oidcIssuerUrl` alone, and a public client no secret.)
239
- The endpoints come from `oidcIssuerUrl`, which the provider discovers them
240
- from, or — without it — from every explicit endpoint the row reads
241
- (`oidcAuthorizationEndpoint`, `oidcDeviceAuthorizationEndpoint`,
242
- `oidcTokenEndpoint`); without either the error names `oidcIssuerUrl` and the
243
- endpoints missing. `oidcScopes` is passed as given (`token_exchange` takes
244
- one `scope` string: the scopes joined by a space). The subject and actor
245
- tokens of `token_exchange` and the user and password of `password` are
246
- means: sent to the token endpoint, never written to the session.
247
-
248
- **The SAML grants.** Each provider validates the assertion before anything
249
- uses it, with a validator the broker composes from the destination's trust
250
- and your replay store: `createSignedResponseValidator` for `saml2_pure` (the
251
- Response must be signed — the cookies' system receives it whole) and
252
- `createSignedAssertionValidator` for `saml2_bearer` (the Assertion must be
253
- signed — the token endpoint receives it alone), from `samlIdpCertificates`
254
- (PEM or base64 DER; several during a rotation), `samlClockSkewMs` and
255
- `assertionReplayStore(destination)`; `samlIdpEntityId` is the issuer every
256
- assertion must name. A certificate the validator cannot read, or a
257
- `samlClockSkewMs` that is not a whole, non-negative number of milliseconds,
258
- is a `DestinationConfigError` naming the field. `samlIdpInitiated: true`
259
- declares an IdP-initiated login: no AuthnRequest, and an assertion carrying
260
- no `InResponseTo` — your strategy then hands over the SAMLResponse without
261
- asking for an authorization URL (a strategy that asks gets Oops from
262
- `prepare()`). `saml2_pure` is seeded with the stored cookies and their
263
- `expiresAt` — cookies carry no expiry of their own, so the provider keeps them
264
- until the assertion's earliest `NotOnOrAfter` (less the provider's one-minute
265
- margin: an identity provider whose assertions live a minute or less makes
266
- every request a login); SAML has no refresh token, so a renewal is a new
267
- login through your strategy. `saml2_bearer` posts the Assertion to
268
- `samlTokenUrl`, else `<uaaUrl>/oauth/token`, with the client, and renews by
269
- its refresh token.
270
-
271
- **The collaborator options** — each a function of the destination, called
272
- once when that destination's provider is built, never disposed by the
273
- broker, and required only by the rows that use it; a row whose option is
274
- missing is a `DestinationConfigError` naming it:
122
+ | `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` | nothing: its subject token is a secret and cannot bind a stored session, so it obtains a fresh token after every restart (no refresh grant: a renewal exchanges again) |
123
+ | `saml` / `saml2_pure` | `Saml2PureProvider` | `samlIdpSsoUrl`, `samlSpEntityId`, `samlIdpEntityId` (the expected issuer), `samlIdpCertificates`; `samlAcsUrl`, `samlRelayState`, `samlIdpInitiated`, `samlClockSkewMs` when set; `serviceUrl`, `sapClient` | the seed: `sessionCookies`, `expiresAt` |
124
+ | `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` |
125
+ | `jwt` / `none` | `TokenAuthProvider.fixed(authorizationToken)` | `authType`, `grantType`, `serviceUrl` (+ `sapClient`); the client and `oidcIssuerUrl` when stated | `authorizationToken` (required), `issuedFor` and `issuedBy` (required to match exactly) |
126
+ | `saml` / `none` | `new SamlAuthProvider(sessionCookies)` | `authType`, `grantType`, `serviceUrl` (+ `sapClient`); `samlAcsUrl` when stated | `sessionCookies` (required), `issuedFor` and `issuedBy` (required to match exactly) |
127
+
128
+ The allowed pairs are `jwt` with `authorization_code`, `client_credentials`, `passcode`,
129
+ `oidc_authorization_code`, `device_code`, `password`, `token_exchange` or `none`, and `saml`
130
+ with `saml2_pure`, `saml2_bearer` or `none`. A pair outside them is a `DestinationConfigError`
131
+ naming `grantType`.
132
+
133
+ **No provider reads `serviceUrl`.** The URL of the system is where the connector connects, not
134
+ authorization data: give the connector its URL from your key store (`getConnectionConfig`). The
135
+ broker reads it, with `sapClient`, for one thing only: to bind a stored secret to the resource
136
+ it was obtained for. A token destination without it still gets its provider, but no stored
137
+ secret is reused for it (except on the `clientAuthentication` strategy path, below).
138
+
139
+ `none` is how a handed-over credential is stated: the key store says `grantType: 'none'`, and
140
+ the token or cookies live in the session. The SNC row takes the contract's own defaults when a
141
+ field is absent — the library is discovered (`SNC_LIB_64`, `SNC_LIB`, the Secure Login Client's
142
+ install path) when `sncLib` is, the user's SNC name comes from the credential when `sncMyName`
143
+ is, and `qop` is the provider's `'9'` when `sncQop` is.
144
+
145
+ **The UAA grants.** The client comes from the key store's `getAuthorizationConfig` — never from
146
+ the session store. A seed is presented while it is valid (a JWT's own `exp` decides; the stored
147
+ `expiresAt` serves a token that carries none), and its refresh token renews it, as the renewal
148
+ strategy says. Without a seed the provider obtains its first token at `prepare()`.
149
+ `uaaClientSecret: ''` is a public client: `passcode` takes it as no secret;
150
+ `AuthorizationCodeProvider` and `ClientCredentialsProvider` require a secret, so for them `''`
151
+ is missing. With a `clientAuthentication` strategy no secret is read or required (see *How the
152
+ Client Authenticates*).
153
+
154
+ **The OIDC grants.** The client is the key store's `getAuthorizationConfig` again: `uaaClientId`,
155
+ and `uaaClientSecret` — `''` is a public client, sent with no secret. The endpoints come from
156
+ `oidcIssuerUrl`, which the provider discovers them from, or — without it — from every explicit
157
+ endpoint the row reads; without either the error names `oidcIssuerUrl` and the endpoints
158
+ missing. `oidcScopes` is passed as given (`token_exchange` takes one `scope` string: the scopes
159
+ joined by a space). The subject and actor tokens of `token_exchange` and the user and password
160
+ of `password` are means: sent to the token endpoint, never written to the session.
161
+
162
+ **The SAML grants.** Each provider validates the assertion before anything uses it, with a
163
+ validator the broker composes from the destination's trust and your replay store:
164
+ `createSignedResponseValidator` for `saml2_pure` (the Response must be signed — the cookies'
165
+ system receives it whole) and `createSignedAssertionValidator` for `saml2_bearer` (the Assertion
166
+ must be signed — the token endpoint receives it alone), from `samlIdpCertificates` (PEM or
167
+ base64 DER; several during a rotation), `samlClockSkewMs` and `assertionReplayStore(destination)`;
168
+ `samlIdpEntityId` is the issuer every assertion must name. A certificate the validator cannot
169
+ read is a `DestinationConfigError` naming `samlIdpCertificates` and carrying the validator's
170
+ error; a `samlClockSkewMs` that is not a whole, non-negative number of milliseconds is one naming
171
+ `samlClockSkewMs`. `samlIdpInitiated: true` declares an IdP-initiated login: no AuthnRequest,
172
+ and an assertion carrying no `InResponseTo` — your strategy then hands over the SAMLResponse
173
+ without asking for an authorization URL. Cookies carry no expiry of their own, so `saml2_pure`
174
+ keeps them until the assertion's earliest `NotOnOrAfter` (less the provider's one-minute
175
+ margin); SAML has no refresh token, so its renewal is a new login through your strategy.
176
+ `saml2_bearer` posts the Assertion to `samlTokenUrl`, else `<uaaUrl>/oauth/token`, with the
177
+ client, and renews by its refresh token.
178
+
179
+ **The collaborator options** — each a function of the destination, called once per build of
180
+ that destination's provider (again for every new build, see *A Credential Stays Bound to Its
181
+ Identity*), never disposed by the broker, and required only by the rows that use it; a row whose
182
+ option is missing is a `DestinationConfigError` naming it:
275
183
 
276
184
  | Option | Rows | What it returns |
277
185
  |---|---|---|
278
- | `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`, …) |
186
+ | `authorization(destination, grant)` | `authorization_code`, `passcode`, `saml2_pure`, `saml2_bearer` (the grant is passed: a `StrategyGrant`) | the `IAuthorizationStrategy<string>` that conducts the login — for `passcode`, the one that asks for the code (handed `<uaaUrl>/passcode` as the URL); for the SAML grants, the one that returns the SAMLResponse |
279
187
  | `oidcAuthorization(destination)` | `oidc_authorization_code` | an `IAuthorizationStrategy<OidcCallbackResult>` (`oidcCallbackStrategy`, or `asOidcResult(…)` over a string strategy) |
280
188
  | `deviceCodePresenter(destination)` | `device_code` | an `IDeviceCodePresenter` that shows the user the verification URL and code (`consoleDeviceCodePresenter(logger)`, or your UI) |
281
189
  | `samlCookies(destination)` | `saml2_pure` | `(samlResponse) => Promise<string>`: posts the validated SAMLResponse to the system's ACS and returns the session cookies it sets |
282
190
  | `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 |
283
191
 
192
+ A strategy you write yourself must honour `AuthorizationRequest.signal` (auth-providers 6):
193
+ when it aborts, the strategy stops waiting and releases what it holds before it settles.
194
+
284
195
  ```typescript
196
+ import { AuthBroker } from '@mcp-abap-adt/auth-broker';
285
197
  import {
198
+ browserCallbackStrategy,
286
199
  consoleDeviceCodePresenter,
287
200
  defaultReplayStore,
201
+ linuxDefaultBrowser,
202
+ manualPasscodeStrategy,
288
203
  oidcCallbackStrategy,
204
+ refreshThenLogin,
289
205
  samlCallbackStrategy,
290
206
  } from '@mcp-abap-adt/auth-providers';
291
207
 
292
- const broker = new AuthBroker(
208
+ const browser = linuxDefaultBrowser(); // macDefaultBrowser(), windowsDefaultBrowser(), …
209
+
210
+ const ssoBroker = new AuthBroker(
293
211
  {
294
212
  serviceKeyStore: myKeyStore,
295
213
  sessionStore: mySessionStore,
214
+ renewal: () => refreshThenLogin(),
215
+ onWriteFailure: 'fail',
296
216
  authorization: (destination, grant) =>
297
- grant === 'saml2_pure' || grant === 'saml2_bearer'
298
- ? samlCallbackStrategy({ timeoutMs: 120_000 })
299
- : browserCallbackStrategy({ timeoutMs: 120_000 }),
300
- oidcAuthorization: () => oidcCallbackStrategy({ timeoutMs: 120_000 }),
217
+ grant === 'passcode'
218
+ ? manualPasscodeStrategy()
219
+ : grant === 'saml2_pure' || grant === 'saml2_bearer'
220
+ ? samlCallbackStrategy({ browser })
221
+ : browserCallbackStrategy({ browser }),
222
+ oidcAuthorization: () => oidcCallbackStrategy({ browser }),
301
223
  deviceCodePresenter: () => consoleDeviceCodePresenter(logger),
302
224
  samlCookies: (destination) => (samlResponse) =>
303
225
  postToAcs(destination, samlResponse), // yours: the system's ACS answers Set-Cookie
@@ -307,165 +229,370 @@ const broker = new AuthBroker(
307
229
  );
308
230
  ```
309
231
 
310
- ### A Secret Is Bound to Its Resource and Issuer
311
-
312
- A token's audience, cookies' host and path, a system's client: presenting a
313
- secret to a resource it was not obtained for is a leak, and a secret from
314
- another authorization server or client must not stand in for this
315
- destination's. So the session store keeps, beside the secret, two strings
316
- (`IConnectionConfig`, `@mcp-abap-adt/interfaces-auth-broker` 1.1.0):
317
-
318
- - **`issuedFor`** — the resource: `serviceUrl` with the SAP client, e.g.
319
- `https://my-abap.example.com:443/sap/bc/adt?sap-client=100`;
320
- - **`issuedBy`** — who issued it, to which client: `uaaUrl` with
321
- `client_id=<uaaClientId>` for the UAA grants and `saml2_bearer`, e.g.
322
- `https://sub.authentication.us10.hana.ondemand.com:443?client_id=sb-abap-trial`;
323
- `oidcIssuerUrl` — else `uaaUrl` — with `client_id=<uaaClientId>` for the
324
- OIDC grants; for `saml2_pure`'s cookies, the ACS of the system that set
325
- them: `samlAcsUrl`, origin and path.
326
-
327
- The broker computes both from the destination's means and compares them,
328
- canonicalised on **both** sides, with what the session holds:
329
-
330
- - **The canonical form:** scheme and host lower-cased; the port explicit
331
- (`443` for `https`, `80` for `http`); the path without a trailing `/` (the
332
- root path is empty); one query parameter at most — `sap-client` (the means'
333
- `sapClient` wins over a `sap-client` already in `serviceUrl`) or
334
- `client_id` — parsed and re-encoded, so a value percent-encoded on one side
335
- and plain on the other compares equal; no user info, no other parameter,
336
- no fragment. A URL that does not parse binds nothing. A store keeps the
337
- strings as given and need not canonicalise them.
338
- - **The grants that obtain a secret** (UAA, OIDC, SAML)**:** a stored secret seeds the provider only when **both**
339
- stored values equal the computed ones. Otherwise — either different, or
340
- absent on either side (no `serviceUrl` in the means, an OIDC destination
341
- stating neither `oidcIssuerUrl` nor `uaaUrl`, a `saml2_pure` one without
342
- `samlAcsUrl`, a session written without them) — the secret is not used,
343
- **refresh token included**: the
344
- provider is built as with no session and logs in afresh by its grant, and
345
- the log says only `<destination>: secret bound to another resource,
346
- discarded` — never a URI, never a token. The new secret is written with
347
- both fields.
348
- - **The `none` rows** present a credential the broker cannot obtain again, so
349
- a mismatch is refused, not discarded: a `DestinationConfigError` naming
350
- `issuedFor` when the stored resource differs or is absent (or the means
351
- state no `serviceUrl`), and naming `issuedBy` when the means state an issuer
352
- (`oidcIssuerUrl` or the client for `jwt`, `samlAcsUrl` for `saml` — then
353
- canonicalised as origin and path) and the stored one differs or is absent.
354
- Means that state no issuer leave `issuedBy` uncompared.
355
- - **The binding does not check the token.** Its audience stays the
356
- resource's to enforce; the broker compares two strings and parses no token.
357
- - **`bindingOf(means, client?)` — for a consumer that hands over a
358
- credential.** A `none` destination presents a token or cookies the broker
359
- cannot obtain; whoever writes them to the session store must write the
360
- binding beside them. `bindingOf` answers exactly what the broker computes for
361
- those means — the one function `getProvider` checks against and `persist`
362
- writes (`issuedFor` from `serviceUrl` + `sapClient`, `issuedBy` from the
363
- issuer, client or ACS the grant uses) — so a consumer never canonicalises a
364
- URI itself:
365
-
366
- ```typescript
367
- const means = await keyStore.getConnectionConfig('DEV'); // saml / none
368
- await sessionStore.saveSession('DEV', {
369
- sessionCookies: cookies,
370
- ...bindingOf(means ?? {}), // { issuedFor: 'https://dev.example.com:443?sap-client=100' }
371
- });
232
+ A login waits until it ends or a signal aborts it — no strategy of auth-providers 6 has a
233
+ timeout. A bound is yours to compose: the strategy's `signal` option, or the signal you pass the
234
+ broker (`AbortSignal.timeout(ms)`).
235
+
236
+ ### How a Token Provider Renews: `renewal`
237
+
238
+ ```typescript
239
+ import type { AuthBrokerConfig } from '@mcp-abap-adt/auth-broker';
240
+ import { refreshOnly, refreshThenLogin } from '@mcp-abap-adt/auth-providers';
241
+
242
+ // The same for every destination: refresh, then log in — what 4.x did.
243
+ const sameForAll: AuthBrokerConfig['renewal'] = () => refreshThenLogin();
244
+
245
+ // Per destination or grant: a headless destination never logs in.
246
+ const perDestination: AuthBrokerConfig['renewal'] = (destination, grant) =>
247
+ destination === 'HEADLESS' || grant === 'device_code' ? refreshOnly() : refreshThenLogin();
248
+ ```
249
+
250
+ `renewal(destination, grant)` is called once per build of every token row — the UAA, OIDC and
251
+ SAML grants (`TokenGrant`: `'authorization_code' | 'client_credentials' | 'passcode' | 'oidc_authorization_code' | 'device_code' | 'password' | 'token_exchange' | 'saml2_pure' | 'saml2_bearer'`) — after every
252
+ other check of the row has passed and before the provider's constructor; its answer is the provider's `renewal`,
253
+ unchanged. The broker never wraps, inspects or calls it.
254
+
255
+ - **Required for every token row, no default.** A token row built without it is a
256
+ `DestinationConfigError` with `missingFields` naming `renewal` — together with every other
257
+ field and option the row lacks, in one error — before any collaborator is called and before
258
+ anything is cached. `basic`, `snc` and the `none` rows build without it.
259
+ - **A `renewal` that throws** is a `DestinationConfigError(['renewal'])`, "the renewal option
260
+ failed", carrying what it threw as auth-errors reads it (`error`); nothing is built or cached.
261
+ A provider that refuses the strategy it was given (one whose `next` is not a function) is a
262
+ `DestinationConfigError` naming `renewal` too, "the provider refused the configuration the
263
+ destination states", carrying the provider's error.
264
+ - **Never called for the token API's consumer `provider`**: an instance or a factory's result
265
+ is your composition, and brings its own renewal.
266
+
267
+ `refreshThenLogin()` refreshes when there is a refresh token and logs in through the row's
268
+ interactive strategy when the refresh is refused or there is none. `refreshOnly()` never logs
269
+ in: a headless process uses it, or an `authorization` strategy that refuses (see *Headless
270
+ Processes*).
271
+
272
+ ### Session Writes: `onWriteFailure`, the Write Queue and `flush()`
273
+
274
+ Every token provider `getProvider` builds writes what it obtains back to the session store
275
+ before it answers, whichever moment triggered the renewal: `prepare()`, `authorize()` on expiry,
276
+ or `rejected()` after a 401. A renewal inside a connector is stored before the connector resends.
277
+ The broker builds each provider's persistence from auth-providers' own
278
+ `refreshStatePersistence` over one write path of its own; you cannot give a provider the broker
279
+ builds a persistence strategy of yours, since the broker writes the binding beside the secret.
280
+
281
+ `onWriteFailure: 'fail'` suits a command or a test that must know the secret landed;
282
+ `onWriteFailure: 'continue'` a long-running server that goes on best effort. **It is required** for every destination that writes a secret — a token row
283
+ `getProvider` builds, and every call of the token API with a `provider` of yours. Without it the
284
+ build (or the call) throws `DestinationConfigError` naming `onWriteFailure`; `basic`, `snc` and
285
+ `none` destinations do not need it. One option serves both paths.
286
+
287
+ - **`'fail'`:** the call whose write did not land fails — `unknown`, `persisting-tokens`
288
+ ("persisting the tokens failed (unknown error, EACCES)", the code only when it is
289
+ allowlisted): for a provider the broker built, the provider's awaited report fails its
290
+ `getTokens()` / `refreshTokens()` or its moment, and the token API relays that failure; on
291
+ the consumer path the token API rejects with the same failure. And **while the destination's
292
+ last write is pending, its calls are refused until a write lands**: `getProvider`, `getToken`
293
+ and `refreshToken` ask "is the destination's last write pending?" on entry and once more right
294
+ before they return success; each time the answer is yes they retry that write — it lands, the
295
+ call goes on; it fails, the call rejects with the same failure. "Pending" is any write not yet
296
+ landed: failed, queued or in flight. **The limit:** a provider already handed to a connector
297
+ answers its moments from its own state; a moment that commits nothing (a valid cached token
298
+ presented) is not refused because of a pending write. Every moment that renews writes, and
299
+ awaits its write.
300
+ - **`'continue'`:** no call fails because of a write. A failed write is logged (`warn`, the
301
+ failure's `logFields` only), stays pending, and the call goes on.
302
+
303
+ **The write queue.** The writes of one destination run one at a time, in the order they were
304
+ queued, so an older write never runs after — and never overwrites — a newer one; destinations
305
+ never wait on each other. A write of a provider that has since been replaced (see *A Credential
306
+ Stays Bound to Its Identity*) is dropped once the new provider has written. **A failed write
307
+ stays pending** — the latest state its provider reported, which every later write of that
308
+ provider carries too — and is retried by the destination's next write or by `flush()`. **There
309
+ is no retry timer**: nothing is retried on its own. Every wait on the queue races its caller's
310
+ signal: an abort releases that caller at once with auth-errors' `aborted` failure — never with
311
+ success — and the write runs on, landing or failing on its own.
312
+
313
+ **The store's contract.** `saveSession` settles: it resolves or rejects. A store whose
314
+ `saveSession` never settles holds its destination's queue; avoiding that is yours (each waiting
315
+ caller is still released by its own signal). auth-stores 4's `saveSession` **merges** — a field
316
+ left out keeps what is stored — so the broker states every field that must not survive:
317
+
318
+ | What the provider reported | What one `saveSession` writes |
319
+ |---|---|
320
+ | a token | `authorizationToken`, `expiresAt`, `refreshToken` (below), `issuedFor` (`''` when the means state no `serviceUrl`), `issuedBy` |
321
+ | `saml2_pure`'s cookies (`tokenType: 'saml'`) | `sessionCookies`, `expiresAt`, `refreshToken: ''` (SAML has none, and one stored beside earlier cookies or a token is not this credential's), `issuedFor`, `issuedBy` |
322
+ | a refresh token discarded before any credential is held | **only** `refreshToken: ''` — the stored credential keeps its own binding and loses its refresh token |
323
+
324
+ **The refresh token a provider owns.** Each provider the broker builds owns a refresh token: the
325
+ one it was seeded with from a session bound to it, or none; then each write updates it — a new
326
+ one written makes it owned, a discard makes it none. A write that reports no new refresh token
327
+ writes the owned one, or `''` when it owns none — never one read from the store at write time.
328
+ So a refresh token is persisted only by the provider that obtained it or was seeded with it and
329
+ has not discarded or replaced it since; a refresh token another provider of the destination
330
+ wrote never ends up beside this provider's credential. `expiresAt` is the provider's report,
331
+ absolute. A destination the key store states as `basic` or `snc` at write time is not written.
332
+
333
+ **`flush({ signal? })`** waits for every write queued so far and gives each pending one one more
334
+ attempt. It resolves when all landed, and rejects with an `AggregateError` ("Session writes
335
+ still failing for "<destination>", …; each stays pending until its destination's next write or
336
+ flush()") whose `errors` are one `SessionWriteFailure` per destination still failing:
337
+ `destination`, and `error` — the store's error as auth-errors classifies it (`unknown`,
338
+ `persisting-tokens`), never its message; its own message is `"<destination>": <reason>`. What
339
+ still fails stays pending. Call it on shutdown — on `SIGTERM`, before a stdio transport closes —
340
+ to know whether every token is stored:
341
+
342
+ ```typescript
343
+ import { SessionWriteFailure } from '@mcp-abap-adt/auth-broker';
344
+
345
+ process.on('SIGTERM', async () => {
346
+ try {
347
+ await broker.flush();
348
+ } catch (error) {
349
+ for (const failure of error instanceof AggregateError ? error.errors : [error]) {
350
+ if (failure instanceof SessionWriteFailure) {
351
+ logger.error(`Not stored: ${failure.destination}: ${failure.error.reason}`);
352
+ }
353
+ }
354
+ }
355
+ process.exit(0);
356
+ });
357
+ ```
358
+
359
+ **Across restarts.** A new broker on the same stores seeds each token row only from a session
360
+ bound to its means, and with its refresh token only when that is not empty:
361
+
362
+ | Before the restart | After it |
363
+ |---|---|
364
+ | a refused refresh discarded the refresh token R, and its `''` write landed | no refresh token: the renewal strategy decides (with `refreshThenLogin()`, a login) |
365
+ | the same, the `''` write still pending when the process ended | `'fail'`: you noticed — every call failed and `flush()` rejected before exit; `'continue'`: **R comes back** from the store |
366
+ | a new refresh token R2 landed | R2 |
367
+ | a token-only result while R was held | R — the one that provider owned |
368
+ | a session obtained under other means | discarded, not seeded (one `warn` line) |
369
+
370
+ A remaining limit, auth-providers' own: a process that dies between a discard and its report
371
+ reaching the broker may present the stored R once after a restart.
372
+
373
+ ### A Credential Stays Bound to Its Identity
374
+
375
+ A token's audience, cookies' host, a refresh token's issuer: presenting a secret to a resource
376
+ it was not obtained for, or for an identity it was not obtained under, is a leak. So the session
377
+ store keeps two strings beside the secret, and the broker reuses a stored secret only when both
378
+ are exactly what the destination's current means give.
379
+
380
+ - **`issuedFor`** — the resource: `serviceUrl` with the SAP client, canonical (4.x's form):
381
+ scheme and host lower-cased, the port explicit (`443` / `80`), the path without a trailing
382
+ `/`, one query parameter `sap-client` (the means' `sapClient` first), e.g.
383
+ `https://my-abap.example.com:443/sap/bc/adt?sap-client=100`. Both sides are canonicalised
384
+ before they are compared.
385
+ - **`issuedBy`** — a versioned record only the broker produces, compared by exact equality and
386
+ never parsed:
387
+
372
388
  ```
389
+ mcp-abap-adt-binding/2;<row>;<clientId>;<uaaUrl>;<oidcIssuerUrl>;<oidcTokenEndpoint>;
390
+ <oidcAuthorizationEndpoint>;<oidcDeviceAuthorizationEndpoint>;<oidcAudience>;
391
+ <samlIdpSsoUrl>;<samlAcsUrl>;<samlTokenUrl>;<certUrl>;<trust>
392
+ ```
393
+
394
+ (one line; broken here for reading). `row` is `authType/grantType` — or `provider/…` for the
395
+ token API's consumer provider. Each address field is **the exact string the row hands its
396
+ provider**, `encodeURIComponent`-encoded, `""` when the row hands it none — never
397
+ canonicalised. `trust` is the lower-case hex SHA-256 of the row's non-secret trust input, or
398
+ `""`.
373
399
 
374
- A destination that states no `jwt` / `saml` type or no grant binds nothing
375
- (`{}`). `mcp-sso … --cookie` writes its cookies this way.
400
+ Which fields each row fills, and its trust input:
401
+
402
+ | Row | Address fields | Trust input (hashed, in order) |
403
+ |---|---|---|
404
+ | UAA (`authorization_code`, `client_credentials`, `passcode`) | `clientId`, `uaaUrl`; `certUrl` when the build read a certificate client | `clientCertificate` (the certificate client's public certificate, when read — never its key) |
405
+ | OIDC (`oidc_authorization_code`, `device_code`, `password`, `token_exchange`) | `clientId`, `oidcIssuerUrl`, `oidcTokenEndpoint`; `oidcAuthorizationEndpoint` (`oidc_authorization_code`), `oidcDeviceAuthorizationEndpoint` (`device_code`), `oidcAudience` (`token_exchange`); `certUrl` as above | `oidcScopes`; `username` (`password`); `oidcSubjectTokenType`, `oidcActorTokenType` (`token_exchange`); `clientCertificate` |
406
+ | `saml2_pure` | `samlIdpSsoUrl`, `samlAcsUrl` | `samlIdpCertificates`, `samlIdpEntityId`, `samlSpEntityId`, `samlClockSkewMs`, `samlIdpInitiated` |
407
+ | `saml2_bearer` | `clientId`, `uaaUrl`, `samlIdpSsoUrl`, `samlAcsUrl`, `samlTokenUrl`; `certUrl` as above | the SAML trust above, then `clientCertificate` |
408
+ | `jwt` / `none` | `clientId`, `uaaUrl`, `oidcIssuerUrl` as the means state them | none |
409
+ | `saml` / `none` | `samlAcsUrl` | none |
410
+ | the token API's consumer factory | `clientId`, `uaaUrl` of the client it was handed; an instance: none | none |
411
+
412
+ **No secret takes part in either string, nor a hash of one** — not the password, the client
413
+ secret, a key, a subject or actor token: a hash in a session file can be checked offline against
414
+ guesses. The consequence: after a restart, a session obtained under a previous password or
415
+ client secret of the *same* user or client may seed (a revoked credential is refused by the
416
+ server and renewed through the renewal strategy); within one process any secret change makes a
417
+ new provider (below).
418
+
419
+ **When a stored secret seeds a provider.** Only the first build of a destination in a broker may
420
+ start from the store, and only when the stored `issuedFor` and `issuedBy` equal the build's
421
+ exactly **and** the build's binding is *fully stated*: its record holds the client the row
422
+ authenticates and every server address its provider sends a credential to:
423
+
424
+ | Row | Fully stated when the record holds |
425
+ |---|---|
426
+ | UAA | `clientId` and `uaaUrl`; and `certUrl` when the build read the certificate client |
427
+ | OIDC | `clientId`; the token endpoint (`oidcTokenEndpoint`, or `oidcIssuerUrl` it is discovered from); for `oidc_authorization_code` the authorization endpoint, for `device_code` the device endpoint (each explicit, or the issuer); `certUrl` as above |
428
+ | `saml2_bearer` | `clientId`, `samlIdpSsoUrl`, and the token endpoint (`samlTokenUrl`, or `uaaUrl`); `certUrl` as above |
429
+ | `saml2_pure` | `samlIdpSsoUrl` and `samlAcsUrl` |
430
+ | `token_exchange`, the token API's consumer provider | never |
431
+
432
+ The token, cookies and expiry then come from the same session read whose binding was checked,
433
+ and so does the refresh token the provider owns from then on. Otherwise the stored secret is not
434
+ used, **refresh token included**: the provider is built as with no session and obtains a new one
435
+ by its grant, and one `warn` line says only `<destination>: the stored session secret is not
436
+ recorded as issued under the destination's current means; not used, the provider obtains a new
437
+ one` — never a URI, a record or a token. A destination that can never be seeded (`token_exchange`,
438
+ means lacking what the record needs, or no `serviceUrl`) gets a `debug` line instead, on every
439
+ start. An OIDC destination with explicit endpoints and a client but no issuer is fully stated,
440
+ and is reused after a restart when its means are unchanged.
441
+
442
+ **The `none` rows** present a credential the broker cannot obtain again, so a mismatch is
443
+ refused, not discarded: a `DestinationConfigError` naming `issuedFor` when the stored resource
444
+ differs or is absent (or the means state no `serviceUrl`), and `issuedBy` when the stored record
445
+ is not exactly the row's.
446
+
447
+ **A provider is never changed.** Every call of `getProvider` and of the token API re-reads what
448
+ the destination's provider was built from — the means, the client, and the certificate client
449
+ when the build read it — and compares everything the build read, exactly (arrays element by
450
+ element, an absent value distinct from `''`), secrets included (held in memory beside the
451
+ provider, never logged, never persisted). Unchanged: the cached provider, as it is. **Anything
452
+ changed, by a single character** — trust, a secret, an address, the row: a **new provider**,
453
+ which starts with nothing — no token, no refresh token, nothing of the old provider and nothing
454
+ of a session written under other means: it logs in. The old one is never handed out again for
455
+ that destination; whoever already holds it keeps it, and its late writes are dropped once the
456
+ new one has written. A rotated client certificate is picked up the same way: at the next call, a
457
+ new provider presenting it.
458
+
459
+ **`bindingOf(means, client?)` — for a consumer that hands over a credential.** A `none`
460
+ destination presents a token or cookies the broker cannot obtain; whoever writes them to the
461
+ session store writes the binding beside them. `bindingOf` answers exactly what `getProvider`
462
+ compares for those means — `issuedFor` and the version-2 `issuedBy` of the row the means state —
463
+ so a consumer never builds a record itself:
464
+
465
+ ```typescript
466
+ import { bindingOf } from '@mcp-abap-adt/auth-broker';
467
+
468
+ const means = await keyStore.getConnectionConfig('DEV'); // saml / none
469
+ await sessionStore.saveSession('DEV', {
470
+ sessionCookies: cookies,
471
+ refreshToken: '', // auth-stores 4 merges: clear what an earlier session left
472
+ ...bindingOf(means ?? {}),
473
+ // { issuedFor: 'https://dev.example.com:443?sap-client=100',
474
+ // issuedBy: 'mcp-abap-adt-binding/2;saml/none;;;;;;;;;;;;' }
475
+ });
476
+ ```
477
+
478
+ A destination that states no `jwt` / `saml` type or no grant binds nothing (`{}`). A token row
479
+ built with a `clientAuthentication` strategy writes more than `bindingOf` knows (the certificate
480
+ client's `certUrl` and certificate), so `bindingOf` is for handed-over credentials.
376
481
 
377
482
  **What you meet:**
378
483
 
379
- 1. **A custom `ISessionStore`** (a database, a message log, a secret store)
380
- must persist `issuedFor` and `issuedBy` beside the secret, and answer
381
- them from `loadSession`. One that drops them still type-checks, but the
382
- broker then never reuses its sessions: every process start is a fresh
383
- login — a browser each time for an interactive grant.
384
- 2. **A headless process whose strategy refuses logins** gets Oops ("login
385
- required") from `prepare()` / `rejected()` on a mismatch, instead of
386
- presenting a foreign secret; log in again with the CLI.
387
- 3. **Changing a destination's URL, SAP client, UAA or client** costs one
388
- fresh login — intended.
389
- 4. **Session files written before auth-stores 3.1.0** keep working: its
390
- session stores answer `issuedFor` from the file's `SAP_URL` (+
391
- `SAP_CLIENT`) and `issuedBy` from `SAP_UAA_URL` + `SAP_UAA_CLIENT_ID`,
392
- which the 3.x broker and CLI wrote with the token — so a stored token whose
393
- URL and client are the destination's is reused. XSUAA sessions written with
394
- an empty URL have no `issuedFor`, and log in once.
395
-
396
- **One provider per destination** for the broker's life, shared with the token
397
- API when the broker has no `provider` option (see *Getting Tokens*):
398
- concurrent first calls share one build, and a build that threw is tried again
399
- on the next call. A destination rewritten from outside is picked up by a new
400
- broker.
484
+ 1. **A custom `ISessionStore`** (a database, a secret store) must persist `issuedFor` and
485
+ `issuedBy` beside the secret, byte for byte, answer them from `loadSession`, take
486
+ `refreshToken: ''` as "clear the stored one", and settle every `saveSession`. One that drops
487
+ them still type-checks, but the broker then never reuses its sessions: every process start
488
+ is a fresh login.
489
+ 2. **The first run after upgrading to 5.0.0** reads every earlier session as unbound (see
490
+ *Migrating to 5.0.0*): one login, or one token request, per token destination.
491
+ 3. **Changing a destination's URL, SAP client, client, grant, any server address or any trust
492
+ value** costs one fresh login — **even a cosmetic change** (a trailing `/`, a case change, a
493
+ port written out): addresses are compared exactly.
494
+ 4. **A headless process whose strategy refuses logins** gets Oops ("login required") from
495
+ `prepare()` / `rejected()` on a mismatch, instead of presenting a foreign secret; log in
496
+ again with the CLI.
497
+
498
+ ### Cancellation: `signal`
499
+
500
+ ```typescript
501
+ const session = new AbortController(); // one per connector session
502
+ const provider = await broker.getProvider('TRIAL', { signal: session.signal });
503
+ const token = await broker.getToken('TRIAL', { signal: AbortSignal.timeout(60_000) });
504
+ const refresher = broker.createTokenRefresher('TRIAL', { signal: session.signal });
505
+ await broker.flush({ signal: AbortSignal.timeout(10_000) });
506
+ session.abort(); // the session closed: a login its provider started now is aborted
507
+ ```
508
+
509
+ Every call takes `BrokerCallOptions`, `{ readonly signal?: AbortSignal | undefined }`. A signal says "this caller no longer needs the answer". Its abort releases **that caller alone**
510
+ from every wait it has — the store reads, the shared build, its write — with auth-errors'
511
+ `aborted` failure (`interactive-login`, `outcome: 'aborted'`, "the authorization was aborted"),
512
+ never with success; the work it waited on runs on for the others. The waiter rules are
513
+ auth-errors' `sharedAttempt`; the broker implements none of its own.
514
+
515
+ - **Concurrent callers of one destination share one resolution**, per path (below). One
516
+ caller's abort rejects only its promise. When every caller has aborted, the attempt leaves the
517
+ slot at once: a caller arriving meanwhile starts a fresh one, and a build that completes after
518
+ its attempt was aborted is never cached, never handed out, and writes nothing.
519
+ - **`getProvider`'s signal is attached to the provider it answers** — built or from the cache —
520
+ when that provider has parties: every token provider and the SNC provider. A login that
521
+ provider starts later in a moment (`rejected()` above all) is aborted once every session
522
+ holding it has aborted its signal. `getProvider` without a signal attaches nothing. Tie the
523
+ signal to the connector's session: abort it when the session closes.
524
+ - **The token API never attaches.** `getToken` / `refreshToken` pass the call's signal to the
525
+ provider's `getTokens({ signal })` / `refreshTokens({ signal })`; a token call can neither keep
526
+ a later moment's login alive nor bound it. `createTokenRefresher(destination, { signal })`
527
+ makes every call of the refresher a waiter with that signal.
528
+ - **A cached provider after every caller has gone** stays cached. Its parties were released by
529
+ their aborts, so a moment's login it starts later runs unbounded — the next caller that gave
530
+ no signal can log in on it; one that gives a signal is attached again.
531
+ - **The `clientAuthentication` strategy** gets the build's attempt as
532
+ `ClientAuthenticationContext.signal`: it aborts when every caller waiting on the build has
533
+ gone. Store reads take no signal (the store contract has none): the caller is released at
534
+ once, the read completes on its own and its result is dropped.
535
+ - **No bound of the broker's own.** The broker sets no timeout, adds no signal of its own and
536
+ has no timer; nothing bounds anybody's wait. A caller that wants a bound passes
537
+ `AbortSignal.timeout(ms)`.
401
538
 
402
539
  ### How the Client Authenticates: `clientAuthentication`
403
540
 
404
- Every grant whose client authenticates to the authorization server — the UAA
405
- grants (`authorization_code`, `client_credentials`, `passcode`), the OIDC
406
- grants (`oidc_authorization_code`, `device_code`, `password`,
407
- `token_exchange`) and `saml2_bearer` — sends the client's secret, as in 4.0.0,
408
- unless you tell the broker otherwise. An XSUAA service key created with
409
- `{"credential-type": "x509"}` holds no secret: it holds a client certificate,
410
- its private key and the mTLS host the certificate is presented to (`certurl`).
411
- How the client authenticates is your choice, stated as a strategy — the broker
412
- never infers it from a key's shape and has no default:
541
+ Every grant whose client authenticates to the authorization server — the UAA grants, the OIDC
542
+ grants and `saml2_bearer` (`ClientAuthenticationGrant`) — sends the client's secret, unless you
543
+ tell the broker otherwise. An XSUAA service key created with `{"credential-type": "x509"}` holds
544
+ no secret: it holds a client certificate, its private key and the mTLS host the certificate is
545
+ presented to (`certurl`). How the client authenticates is your choice, stated as a strategy —
546
+ the broker never infers it from a key's shape and has no default:
413
547
 
414
548
  ```typescript
415
- import {
416
- AuthBroker,
417
- fromServiceKeyCertificate,
418
- } from '@mcp-abap-adt/auth-broker';
419
- import {
420
- XsuaaServiceKeyStore,
421
- XsuaaSessionStore,
422
- } from '@mcp-abap-adt/auth-stores';
549
+ import { AuthBroker, fromServiceKeyCertificate } from '@mcp-abap-adt/auth-broker';
550
+ import { refreshThenLogin } from '@mcp-abap-adt/auth-providers';
551
+ import { XsuaaServiceKeyStore, XsuaaSessionStore } from '@mcp-abap-adt/auth-stores';
423
552
 
424
- const broker = new AuthBroker({
553
+ const x509Broker = new AuthBroker({
425
554
  serviceKeyStore: new XsuaaServiceKeyStore('/path/to/keys', {
426
555
  grantType: 'client_credentials',
427
556
  }),
428
557
  sessionStore: new XsuaaSessionStore('/path/to/sessions'),
558
+ renewal: () => refreshThenLogin(),
559
+ onWriteFailure: 'fail',
429
560
  // The key's certificate, presented at <certurl>/oauth/token.
430
561
  clientAuthentication: fromServiceKeyCertificate(),
431
562
  });
432
563
 
433
- const provider = await broker.getProvider('mcp'); // its client: the certificate
564
+ const certificateProvider = await x509Broker.getProvider('mcp');
434
565
  ```
435
566
 
436
- **The strategy** is `(context) => Promise<IClientAuthentication>`
437
- (`ClientAuthenticationStrategy`). The broker calls it once when it builds a
438
- destination's provider, with a `ClientAuthenticationContext`:
439
-
440
- - `destination` and `grant` (a `ClientAuthenticationGrant`, one of the eight
441
- above);
442
- - `client` — the secret client the key store's `getAuthorizationConfig`
443
- answered, as `uaaUrl`, `uaaClientId` and `uaaClientSecret` only — never a
444
- refresh token or any other field the store answered with it — or `null`
445
- (an x509 key answers `null` there, never a client with an empty secret);
446
- - `readCertificate()` — the key store's certificate client
447
- (`IClientCertificate`: `uaaUrl`, `clientId`, `certificate`, `key`,
448
- `certUrl`), read **only when called**, at most once per build; `null` when
449
- the store holds none or implements no `getClientCertificate`.
450
-
451
- Its answer goes to the provider as `clientAuthentication`, and **no client
452
- secret goes with it** (auth-providers 5.3 refuses both). A given strategy
453
- always answers or throws: there is no "nothing" answer, so an explicit choice
454
- never falls back to the secret. `saml2_pure`, the `none` rows, `basic` and
455
- `snc` authenticate no client and never call it.
567
+ **The strategy** is `(context) => Promise<IClientAuthentication>` (`ClientAuthenticationStrategy`).
568
+ The broker calls it once per build of a destination's provider, with a
569
+ `ClientAuthenticationContext`:
570
+
571
+ - `destination` and `grant` (a `ClientAuthenticationGrant`);
572
+ - `client` — the secret client the key store's `getAuthorizationConfig` answered, as `uaaUrl`,
573
+ `uaaClientId` and `uaaClientSecret` only — never a refresh token — or `null` (an x509 key
574
+ answers `null` there);
575
+ - `readCertificate()` — the key store's certificate client (`IClientCertificate`: `uaaUrl`,
576
+ `clientId`, `certificate`, `key`, `certUrl`), read **only when called**, at most once per
577
+ build; `null` when the store holds none or implements no `getClientCertificate`;
578
+ - `signal` — the build's attempt (see *Cancellation*).
579
+
580
+ Its answer goes to the provider as `clientAuthentication`, and **no client secret goes with it**
581
+ (auth-providers 6 refuses both). A given strategy always answers or throws: there is no
582
+ "nothing" answer, so an explicit choice never falls back to the secret. `saml2_pure`, the `none`
583
+ rows, `basic` and `snc` authenticate no client and never call it.
456
584
 
457
585
  **The two shipped factories** each fail closed:
458
586
 
459
- | Factory | Answers | Throws (→ the refusal below) |
587
+ | Factory | Answers | Refuses |
460
588
  |---|---|---|
461
589
  | `fromServiceKeyCertificate()` | auth-providers' `tlsClientCertificate` with the certificate and key `readCertificate()` answers, against `<certUrl>/oauth/token` (a trailing `/` of `certUrl` dropped). The material is checked before the factory answers, so a malformed, incomplete or expired certificate is refused when the provider is built, not at its first token request | the store answers no certificate client: "the destination has no client certificate" |
462
590
  | `fromServiceKeySecret({ encoding })` | auth-providers' `clientSecretBasic` with the secret client's `uaaClientSecret`, in an `Authorization: Basic` header. `encoding` is required: `'raw'` for XSUAA (measured — it does not form-decode), `'form'` for UAA and Keycloak (RFC 6749 §2.3.1); anything else is a `TypeError` when the factory is made | no secret client, or an empty secret: "the destination has no client secret" |
463
591
 
464
- **Composing them is yours.** A fallback, and its order, is your statement,
465
- never the broker's. Branch on what the key holds (`readCertificate()` is
466
- memoised, so the factory reads the same answer); do not `catch` a factory's
467
- refusal and fall back — that would also swallow an expired, incomplete or
468
- unreadable certificate and send the secret instead:
592
+ **Composing them is yours.** A fallback, and its order, is your statement, never the broker's.
593
+ Branch on what the key holds (`readCertificate()` is memoised, so the factory reads the same
594
+ answer); do not `catch` a factory's refusal and fall back — that would also swallow an expired,
595
+ incomplete or unreadable certificate and send the secret instead:
469
596
 
470
597
  ```typescript
471
598
  import {
@@ -475,8 +602,6 @@ import {
475
602
  } from '@mcp-abap-adt/auth-broker';
476
603
 
477
604
  // The certificate when the key holds one, else the secret in a Basic header.
478
- // Decided by what the key holds, never by a failure: an expired, incomplete or
479
- // unreadable certificate is refused, not replaced by the secret.
480
605
  const certificateElseSecret: ClientAuthenticationStrategy = async (context) =>
481
606
  (await context.readCertificate())
482
607
  ? fromServiceKeyCertificate()(context)
@@ -490,31 +615,17 @@ const byGrant: ClientAuthenticationStrategy = (context) =>
490
615
  ```
491
616
 
492
617
  **Where the certificate comes from** — `IServiceKeyStore.getClientCertificate?`
493
- (`@mcp-abap-adt/interfaces-auth-broker` 1.2.0, optional), which auth-stores
494
- 3.3.0 implements:
495
-
496
- - **`XsuaaServiceKeyStore`** — a key (bare, or wrapped in `credentials`)
497
- carrying `url`, `clientid`, `certificate`, `key` and `certurl` and no
498
- `clientsecret` is an x509 key: `getAuthorizationConfig` answers `null`,
499
- `getClientCertificate` the certificate client. A key carrying both a secret
500
- and a complete certificate offers both — the secret client as before, the
501
- certificate client beside it — and your strategy picks; the store does not
502
- read `credential-type` to choose. A key carrying only part of a
503
- certificate client is refused by `getClientCertificate` (`incomplete`),
504
- naming the missing fields, never a value. `AbapServiceKeyStore` holds no
505
- certificate client.
506
- - **`EnvDestinationStore`** — three means variables, each a path or a URL,
507
- never PEM: `SAP_UAA_CLIENT_CERT_PATH`, `SAP_UAA_CLIENT_KEY_PATH`,
508
- `SAP_UAA_CERT_URL` (`XSUAA_UAA_CLIENT_CERT_PATH`, … with
509
- `XSUAA_DESTINATION_VARS`), beside `SAP_UAA_URL` / `SAP_UAA_CLIENT_ID` and
510
- **without** `SAP_UAA_CLIENT_SECRET`. All three and no secret is a
511
- certificate client (the two files read when `getClientCertificate` is
512
- called); none of them is 4.0.0's answer; some but not all, or any beside the
513
- secret, is the store's `ClientCertificateError` from both methods, naming
514
- the variables. A file that states neither a client id, a secret nor any of
515
- the three asks its `fallback` — so an `EnvDestinationStore` over an `XsuaaServiceKeyStore`
516
- answers the x509 key's certificate client. See auth-stores' README for the
517
- details.
618
+ (`@mcp-abap-adt/interfaces-auth-broker`, optional), which auth-stores implements:
619
+
620
+ - **`XsuaaServiceKeyStore`** — a key (bare, or wrapped in `credentials`) carrying `url`,
621
+ `clientid`, `certificate`, `key` and `certurl` and no `clientsecret` is an x509 key:
622
+ `getAuthorizationConfig` answers `null`, `getClientCertificate` the certificate client. A key
623
+ carrying both a secret and a complete certificate offers both, and your strategy picks.
624
+ `AbapServiceKeyStore` holds no certificate client.
625
+ - **`EnvDestinationStore`** — three means variables, each a path or a URL, never PEM:
626
+ `SAP_UAA_CLIENT_CERT_PATH`, `SAP_UAA_CLIENT_KEY_PATH`, `SAP_UAA_CERT_URL`
627
+ (`XSUAA_UAA_CLIENT_CERT_PATH`, … with `XSUAA_DESTINATION_VARS`), beside `SAP_UAA_URL` /
628
+ `SAP_UAA_CLIENT_ID` and **without** `SAP_UAA_CLIENT_SECRET`. See auth-stores' README.
518
629
 
519
630
  ```env
520
631
  # A certificate destination — EnvDestinationStore (ABAP_DESTINATION_VARS)
@@ -528,264 +639,65 @@ SAP_UAA_CLIENT_KEY_PATH=/home/you/keys/client.key
528
639
  SAP_UAA_CERT_URL=https://your-account.authentication.cert.us10.hana.ondemand.com
529
640
  ```
530
641
 
531
- **Without a strategy nothing changes:** the client secret, exactly as 4.0.0,
532
- and nothing certificate-related is read — no `getClientCertificate`, no file.
533
- A client row whose key store answers no client, or one without a client id,
534
- now says, after 4.0.0's words, `a certificate client needs a clientAuthentication strategy` (see
535
- *Migrating from 4.0.0*).
536
-
537
- **With a strategy, what the row requires** is its client's identity —
538
- `uaaUrl` and `uaaClientId` (the OIDC rows: `uaaClientId`) — taken from the
539
- secret client when the key store has one, else from the certificate client
540
- (`uaaUrl`, `clientId`), read through the same memoised `readCertificate`; the
541
- secret is no longer required.
542
-
543
- **The binding on the strategy path.** A secret is bound to the client's
544
- identity (`issuedBy` = `uaaUrl` + `client_id`), never to how the client
545
- authenticates and never to PEM:
546
-
547
- - a certificate destination's tokens are stored with `issuedBy` from the
548
- certificate client's `uaaUrl` and `clientId`, reused after the broker is
549
- recreated, and dropped after the issuer or the client id changes;
550
- - a secret key and an x509 key of the **same client id** at the same
551
- `uaaUrl` (as an XSUAA instance's keys are) share one session: switching the
552
- destination from one to the other keeps its token and refresh token;
553
- - the resource (`issuedFor`) matches when both sides state it and it is
554
- equal — **or when neither does**: means with no `serviceUrl`, or one that
555
- does not parse (the CLI's XSUAA placeholder is such a URL), and a session
556
- stored without `issuedFor`. A resource stated on one side only never
557
- matches. So an XSUAA destination without a service URL reuses its own
558
- session instead of logging in on every run; the client identity still
559
- decides. (Without a strategy, 4.0.0's rule stands: an absent resource
560
- matches nothing.)
561
-
562
- **The token API with a factory of yours** gets the same strategy. For a
563
- destination whose grant authenticates a client, the broker resolves the
564
- strategy first and calls your factory with a **fourth argument**,
565
- `TokenProviderClient` — never a certificate, a key or a secret:
566
-
567
- | Field | What it is |
568
- |---|---|
569
- | `clientAuthentication` | the strategy's answer, to hand to your provider |
570
- | `uaaUrl`, `clientId` | the client's identity: the secret client's when the stores hold one, else the certificate client's; absent when there is neither |
571
- | `refreshToken` | the refresh token the session stored — only when the session is bound to this resource and this client identity, and only from that session read (a refresh token a store's `getAuthorizationConfig` carries is dropped on this path; `authConfig` carries the same bound one) |
642
+ **Without a strategy** the client secret goes to the provider and nothing certificate-related is
643
+ read. A client row whose key store answers no client, or one without a client id, is refused
644
+ naming the fields, and its message adds `a certificate client needs a clientAuthentication
645
+ strategy`.
572
646
 
573
- ```typescript
574
- import {
575
- AuthBroker,
576
- fromServiceKeyCertificate,
577
- type TokenProviderFactory,
578
- } from '@mcp-abap-adt/auth-broker';
579
- import { ClientCredentialsProvider } from '@mcp-abap-adt/auth-providers';
580
- import {
581
- XsuaaServiceKeyStore,
582
- XsuaaSessionStore,
583
- } from '@mcp-abap-adt/auth-stores';
647
+ **With a strategy, what the row requires** is its client's identity — `uaaUrl` and
648
+ `uaaClientId` (the OIDC rows: `uaaClientId`) — taken from the secret client when the key store
649
+ has one, else from the certificate client (`uaaUrl`, `clientId`); the secret is no longer
650
+ required. The record names that identity, and — when the build read the certificate client —
651
+ its `certUrl` and, in the trust digest, its certificate. On this path a resource neither the
652
+ means nor the stored session states matches (an XSUAA destination without a service URL reuses
653
+ its own session); a resource stated on one side only never matches.
584
654
 
585
- const certificateClient: TokenProviderFactory = (
586
- destination,
587
- _authConfig, // null for an x509 key
588
- _connConfig,
589
- client,
590
- ) => {
591
- if (!client?.clientAuthentication || !client.uaaUrl || !client.clientId) {
592
- throw new Error(`No client for ${destination}`);
593
- }
594
- return new ClientCredentialsProvider({
595
- uaaUrl: client.uaaUrl,
596
- clientId: client.clientId,
597
- clientAuthentication: client.clientAuthentication,
598
- });
599
- };
600
-
601
- const tokenBroker = new AuthBroker({
602
- serviceKeyStore: new XsuaaServiceKeyStore('/path/to/keys', {
603
- grantType: 'client_credentials',
604
- }),
605
- sessionStore: new XsuaaSessionStore('/path/to/sessions'),
606
- clientAuthentication: fromServiceKeyCertificate(),
607
- provider: certificateClient,
608
- });
609
-
610
- const token = await tokenBroker.getToken('mcp');
611
- ```
612
-
613
- On this path:
614
-
615
- - the destination must state its `authType` (`jwt` or `saml`) and
616
- `grantType` — the strategy is told the grant — else a
617
- `DestinationConfigError` naming `authType`, `grantType`; a `saml2_pure` or
618
- `none` destination authenticates no client, and its factory gets three
619
- arguments, as in 4.0.0;
620
- - every stored secret your factory is seeded with passes only when the
621
- session is bound to this resource and this client identity: the refresh
622
- token (in the fourth argument, and in `authConfig` for a secret client), and
623
- the stored token, cookies and expiry in `connConfig` — so a refresh token
624
- can never reach another authorization server. A result without a refresh
625
- token carries forward only a bound one. (Without a strategy, 4.0.0's
626
- carry-over stands: the stored refresh token whatever its binding.)
627
- - what the factory is handed is built from allowlists, never a store's
628
- answer passed whole: `authConfig` is `uaaUrl`, `uaaClientId`,
629
- `uaaClientSecret` and the bound refresh token; `connConfig` is
630
- `serviceUrl`, `sapClient`, `language`, `authType`, `grantType`, plus — when
631
- that same connection read is bound — `authorizationToken`,
632
- `sessionCookies`, `expiresAt`, `issuedFor`, `issuedBy`. Anything else a
633
- store answers (a password, grant data, a field outside the type) stays
634
- behind; without a strategy the reads go through as in 4.0.0;
635
- - a factory that throws is a `DestinationConfigError` naming `provider`,
636
- `clientAuthentication`, in fixed words — what it threw is not kept;
637
- - a provider **instance** given as `provider` is your own composition: the
638
- token API uses it as given and calls no strategy for it.
639
-
640
- **A strategy that fails carries nothing out.** Whatever the strategy, a store
641
- it reads or the certificate check throws — and an answer that is no
642
- `IClientAuthentication` — becomes a `DestinationConfigError` with
643
- `missingFields: ['clientAuthentication']`, before any provider exists, in
644
- fixed words chosen by the error's class only — no `cause`, nothing of the
645
- thrown message:
655
+ **A strategy that fails carries nothing out but what auth-errors admits.** Whatever the
656
+ strategy, a store it reads or the certificate check throws — and an answer that is no
657
+ `IClientAuthentication` — becomes a `DestinationConfigError` with `missingFields:
658
+ ['clientAuthentication']`, before any provider exists:
646
659
 
647
660
  | Words (after `Destination "<name>": `) | When |
648
661
  |---|---|
649
662
  | `the clientAuthentication strategy refused: the destination has no client certificate` | `fromServiceKeyCertificate()`, no certificate client |
650
663
  | `the clientAuthentication strategy refused: the destination has no client secret` | `fromServiceKeySecret()`, no secret client or an empty secret |
651
- | `the clientAuthentication strategy refused: the client certificate is incomplete` / `has expired` / `could not be used` | the certificate check refused the material |
664
+ | `the clientAuthentication strategy failed: <reason>` | anything else; `error` carries what was thrown as auth-errors reads it — for an unusable certificate, auth-providers' `client-certificate` failure, whose reason is `the client certificate is incomplete`, `… could not be used` or `… has expired` (its `hint` in `error.hint`); for your own error, its classification only |
652
665
  | `the clientAuthentication strategy answered no client authentication` | the answer has no `authenticate` function |
653
666
  | `the client certificate could not be read` | the store failed reading the certificate client for the row's identity |
654
- | `the clientAuthentication strategy failed` | anything else — your strategy's own error, a store's error (an incomplete `.env`), an unreadable file |
655
-
656
- A certificate, a key or a file's content never reaches a log line, a refusal,
657
- a `DestinationConfigError`, a thrown message or the session store.
658
-
659
- **The certificate is pinned for the broker's lifetime.** It is read when the
660
- destination's provider is built, and that provider is cached per destination
661
- for as long as the `AuthBroker` lives. An XSUAA x509 key's certificate lives
662
- about seven days: a long-running process then gets `the client certificate
663
- has expired` from its provider until it builds a new `AuthBroker`, and
664
- replacing the key or the PEM files changes nothing before that. Rotating
665
- certificates is out of scope: create a new key, then a new broker (restart).
666
-
667
- **What is measured.** `client_credentials` with an x509 XSUAA service key,
668
- against XSUAA on a BTP trial (2026-10-05; the instance's `credential-types`
669
- `["binding-secret", "x509"]`, its key created with
670
- `{"credential-type": "x509"}`): through the broker
671
- (`fromServiceKeyCertificate()` + `XsuaaServiceKeyStore`, `getProvider` and the
672
- token API), through `mcp-auth --client-auth certificate` and through
673
- `generate-env-from-service-key --client-auth certificate`, each followed by a
674
- fresh broker over the written destination — see *Testing*. **Not measured:**
675
- `authorization_code` and `passcode` over x509 (the broker builds them, nothing
676
- has run them against XSUAA), the OIDC grants and `saml2_bearer` with a
677
- strategy, and ABAP environment service keys with x509 (`AbapServiceKeyStore`
678
- holds no certificate client).
679
-
680
- ### Persistence and `flush()`
681
-
682
- Every token provider `getProvider` builds writes what it obtains back to the
683
- session store, through its `onTokens` hook, before it answers — whichever
684
- moment triggered the renewal: `prepare()`, `authorize()` on expiry, or
685
- `rejected()` after a 401. A renewal inside a connector is stored before the
686
- connector resends.
687
-
688
- - **The secret alone, with its binding, in one write**:
689
- `saveSession(destination, { authorizationToken, expiresAt, refreshToken,
690
- issuedFor, issuedBy })` — or, for `saml2_pure`'s cookies (a result of
691
- `tokenType: 'saml'`), `{ sessionCookies, expiresAt, issuedFor, issuedBy }`,
692
- with no refresh token: SAML has none. `saml2_bearer`'s result is a token
693
- and is written as one — `issuedFor` / `issuedBy` as computed when the
694
- provider was built, each left out when the means lack its source (so the
695
- store clears it). No means is ever written — not `serviceUrl`, not
696
- `authType`, not the client: the client secret lives in the key store, and
697
- the broker never writes the key store (its contract has no write method).
698
- `expiresAt` is the result's, else the `expires_in` it reported counted from
699
- when it arrived. A result without a refresh token keeps the one the session
700
- holds — when that one is bound where the new secret is; one bound to
701
- another resource or issuer is not carried into it.
702
- - **`basic` and `snc` are never written**: they obtain no session secret, and
703
- `none` obtains nothing either. A destination the key store states as
704
- `basic` or `snc` at write time is not written.
705
- - **The token API writes through the same path** (see *Getting Tokens*):
706
- with a `provider` of yours, every answer it gets is written as above — the
707
- stored refresh token carried forward whatever its binding, as your provider
708
- was seeded with it.
709
- - **A failed write does not fail the authentication.** The connector goes on
710
- with the token it holds; the broker keeps the result pending for that
711
- destination and retries on its own — one second, doubling, capped at one
712
- minute — on a timer that never keeps the process alive. A newer result
713
- replaces the pending one; writes for one destination never overlap. Each
714
- failure is logged by its class name, never its message. The token API's
715
- caller still gets the failure: `getToken()` / `refreshToken()` throw the
716
- store's error for the token they received, while the retry goes on.
717
- - **`flush()`** gives every pending write one more attempt, resolves when all
718
- have landed, and rejects (an `AggregateError` naming the destinations; each
719
- of its `errors` is `"<destination>": <error class>` — never the store's own
720
- message, which may quote what was being written) when the store still
721
- refuses — the broker keeps retrying.
722
- Call it on shutdown — on `SIGTERM`, before a stdio transport closes — to
723
- know whether every token is stored.
667
+ | `the client certificate the key store answered holds no certificate` | a certificate client whose `certificate` is not a string |
724
668
 
725
- ```typescript
726
- process.on('SIGTERM', async () => {
727
- try {
728
- await broker.flush();
729
- } catch (error) {
730
- logger.error(`Tokens not stored: ${(error as Error).message}`);
731
- }
732
- process.exit(0);
733
- });
734
- ```
669
+ A certificate, a key or a file's content never reaches a log line, a refusal, a
670
+ `DestinationConfigError`, a thrown message or the session store.
735
671
 
736
- **A destination that lacks what its type needs** is a `DestinationConfigError`
737
- (see *Error Handling*), thrown by `getProvider` before any provider exists —
738
- never a provider built only to refuse in `prepare()`.
672
+ **What is measured.** `client_credentials` with an x509 XSUAA service key, against XSUAA on a BTP
673
+ trial (2026-10-05, on 4.1.0: `fromServiceKeyCertificate()` + `XsuaaServiceKeyStore`, through
674
+ `getProvider` and the token API, and through the CLI) — see *Testing*. **Not measured:**
675
+ `authorization_code` and `passcode` over x509, the OIDC grants and `saml2_bearer` with a
676
+ strategy, and ABAP environment service keys with x509.
739
677
 
740
678
  ### Headless Processes (No Browser)
741
679
 
742
- Whether a login may open a browser is the provider's authorization strategy,
743
- not a broker option. A process nobody is watching (an MCP server on stdio, a
744
- CI job) gives the provider a strategy that refuses, and catches its own error —
745
- the broker hands it back unchanged:
680
+ Whether a login may happen is the renewal strategy's and the interactive strategy's, not a
681
+ broker switch. A process nobody is watching (an MCP server on stdio, a CI job) gives the broker
682
+ `renewal: () => refreshOnly()` — never a login — or strategies that refuse:
746
683
 
747
684
  ```typescript
748
- class LoginRequiredError extends Error {}
749
-
750
- const provider = new AuthorizationCodeProvider({
751
- uaaUrl, clientId, clientSecret, refreshToken,
752
- authorization: {
753
- authorize: async () => {
754
- throw new LoginRequiredError('Run mcp-auth to log in');
755
- },
756
- },
757
- });
758
-
759
- try {
760
- await broker.getToken('TRIAL');
761
- } catch (error) {
762
- if (error instanceof LoginRequiredError) {
763
- // No usable token or refresh token: a person has to log in.
764
- }
765
- }
766
- ```
767
-
768
- A cached token that is still valid, or a refresh token the UAA accepts, never
769
- reaches the strategy.
685
+ import { AuthBroker } from '@mcp-abap-adt/auth-broker';
686
+ import { refreshThenLogin } from '@mcp-abap-adt/auth-providers';
770
687
 
771
- With `getProvider` the same holds for every collaborator option: give the
772
- broker an `authorization` / `oidcAuthorization` that refuses, and a
773
- `deviceCodePresenter` that routes the code to wherever a person can see it —
774
- or refuses. The provider then runs on its stored secret and refresh token;
775
- when a login is needed, `prepare()` / `rejected()` answer Oops — whose
776
- refusal carries fixed wording, never the message of what your collaborator
777
- threw — and nothing is written:
688
+ class LoginRequiredError extends Error {}
778
689
 
779
- ```typescript
780
690
  const refuseLogin = {
781
- authorize: async () => {
691
+ authorize: async (): Promise<never> => {
782
692
  throw new LoginRequiredError('Run mcp-auth to log in');
783
693
  },
784
694
  };
785
695
 
786
- const broker = new AuthBroker({
696
+ const headless = new AuthBroker({
787
697
  serviceKeyStore: myKeyStore,
788
698
  sessionStore: mySessionStore,
699
+ renewal: () => refreshThenLogin(),
700
+ onWriteFailure: 'continue',
789
701
  authorization: () => refuseLogin,
790
702
  oidcAuthorization: () => refuseLogin,
791
703
  deviceCodePresenter: () => ({
@@ -796,245 +708,284 @@ const broker = new AuthBroker({
796
708
  });
797
709
  ```
798
710
 
799
- ### Custom Browser Auth Port
711
+ The provider runs on its stored secret and refresh token; when a login is needed, `prepare()` /
712
+ `rejected()` answer Oops and `getToken()` rejects with the provider's `AuthProviderFailure` —
713
+ fixed wording, never the message of what your strategy threw — and nothing is written. A cached token that is still valid, or a refresh token the
714
+ server accepts, never reaches the strategy.
800
715
 
801
- How a login is conducted — including which port the local OAuth2 callback
802
- listens on — is an `IAuthorizationStrategy` passed as `authorization`, not a
803
- field on the provider config. `browserCallbackStrategy` from
804
- `@mcp-abap-adt/auth-providers` builds the ready-made one; its default port is
805
- `61001`, chosen to sit well clear of the range application servers and
806
- proxies typically use (e.g. `3001`/`3333`). Pass `port` to avoid conflicts
807
- with a specific redirect URI registered at the identity provider:
716
+ ### Custom Callback Port
717
+
718
+ How a login is conducted — including which port the local OAuth2 callback listens on — is the
719
+ strategy you return from `authorization`, not a broker option. `browserCallbackStrategy` from
720
+ `@mcp-abap-adt/auth-providers` listens on `61001` by default, clear of the range application
721
+ servers and proxies typically use (`3001` / `3333`). Pass `port` to match a redirect URI
722
+ registered at the identity provider:
808
723
 
809
724
  ```typescript
810
- new AuthorizationCodeProvider({
811
- uaaUrl, clientId, clientSecret,
812
- authorization: browserCallbackStrategy({ browser: 'system', port: 4001 }),
813
- });
725
+ import type { AuthBrokerConfig } from '@mcp-abap-adt/auth-broker';
726
+ import { browserCallbackStrategy, linuxDefaultBrowser } from '@mcp-abap-adt/auth-providers';
727
+
728
+ const onPort4001: AuthBrokerConfig['authorization'] = () =>
729
+ browserCallbackStrategy({ browser: linuxDefaultBrowser(), port: 4001 });
814
730
  ```
815
731
 
816
- ### Getting Tokens
732
+ Every listener is loopback-only; a user whose browser is on another machine tunnels the port
733
+ (`ssh -L`).
734
+
735
+ ### Getting Tokens: the Token API
817
736
 
818
737
  ```typescript
819
- // The provider's current token: cached while valid, else refreshed or logged in.
738
+ // The provider's current token: cached while valid, else renewed as its strategy says.
820
739
  const token = await broker.getToken('TRIAL');
821
740
 
822
741
  // A new token, never the cached one — after the server refused the token (401).
823
- const newToken = await broker.refreshToken('TRIAL');
742
+ const newToken = await broker.refreshToken('TRIAL', { signal });
824
743
  ```
825
744
 
826
- **Which provider answers.**
827
-
828
- - **No `provider` option** — the destination's own: the very provider
829
- `getProvider('TRIAL')` hands out, from the same per-destination cache. A
830
- connector and the token API in one process therefore share one token, one
831
- refresh token and one renewal: a `refreshToken()` while the connector renews
832
- in `rejected()` joins that renewal, and a token the connector renewed is
833
- what `getToken()` answers next. The destination must state a grant that
834
- obtains a token — every grant but `none` (see the table under *A Provider
835
- for a Connector*); a `none` destination is a `DestinationConfigError`
836
- naming `provider`, before any provider is built.
837
- - **A `provider` option** — yours, as in 3.x (see *Basic Usage*): the factory
838
- is called once per destination (concurrent first calls build once; a call
839
- after one that threw builds again), an instance serves every destination.
840
-
841
- **What is written.** The session secret alone, in one `saveSession` — the
842
- token, or the cookies of a SAML result (`tokenType: 'saml'`), its
843
- `expiresAt`, the refresh token (the stored one carried forward when the
844
- result has none), and what the secret is bound to (`issuedFor`, `issuedBy`,
845
- see *A Secret Is Bound to Its Resource and Issuer*). Never `serviceUrl`,
846
- `authType` or a client: they are means, and live in the key store.
847
-
848
- - Without a `provider`, the token API writes nothing itself: the provider's
849
- `onTokens` writes every token it obtains (see *Persistence and `flush()`*),
850
- and a cache hit writes nothing.
851
- - With a `provider`, the token API writes after every answer, cache hits
852
- included, through the same write path. `issuedFor` is the `serviceUrl` it
853
- resolved, with the SAP client; `issuedBy` the client a factory was handed —
854
- an instance is handed none, so what it obtains is bound to no issuer and
855
- `getProvider` never seeds from it. Both are fixed when your provider is
856
- built for the destination (an instance: at its first call for it) and kept
857
- with it: a URL or client changed later never re-labels a token that
858
- provider obtained — a new broker picks the change up.
859
-
860
- **A write that fails** reaches the caller: `getToken()` / `refreshToken()`
861
- throw the store's error, as raised, for the token they received — and the
862
- broker keeps retrying that write on its own; `flush()` tells you when it
863
- landed.
864
-
865
- **A destination stated `basic` or `snc` has no token API.** The token API
866
- reads the destination's `authType` from the key store first and, for `basic`
867
- or `snc`, throws `DestinationConfigError` naming `authType` before any provider
868
- is asked — a token written over such a session would replace a credential
869
- that is not one. A key store that states no `authType` (a 3.x setup), or no
870
- key store, is served as before.
871
-
872
- **Two token sources, with a `provider` option.** `getProvider` never uses
873
- the `provider` option: the destination states its own provider. A process
874
- that gives the broker a `provider` and also calls `getProvider` for the same
875
- destination has two token sources for it — each with its own token and
876
- renewal, both writing to the same session. Use one of them per destination;
877
- or, to hand your own provider to a connector, wrap the token API:
878
- `TokenAuthProvider.from(broker.createTokenRefresher('TRIAL'))`
745
+ **Two paths, two providers.** The token API answers from one of two paths, each resolved, cached
746
+ and bound on its own:
747
+
748
+ - **No `provider` option — the row path:** the destination's own provider, the very one
749
+ `getProvider` hands out (the same cache; never through `getProvider` itself). A connector and
750
+ the token API in one process share one token, one refresh token and one renewal; the token API
751
+ writes nothing itself (the provider's persistence does, and a cache hit writes nothing). The
752
+ destination must state a grant that obtains a token; a `none` destination is a
753
+ `DestinationConfigError` naming `provider`.
754
+ - **A `provider` option — the consumer path:** yours. An instance is used as given, for every
755
+ destination; a factory is called once per destination and again whenever what it was handed
756
+ changes (its new provider starting with nothing). Every answer — cache hits included — is
757
+ written through the same write queue, raced against the call's signal, with the binding fixed
758
+ when the provider was taken into use (`issuedFor` the URL with the SAP client, `issuedBy` the
759
+ `provider/…` record): the result is authoritative — a result without a refresh token writes
760
+ `refreshToken: ''`.
761
+
762
+ ```typescript
763
+ import { AuthBroker, type TokenProviderFactory } from '@mcp-abap-adt/auth-broker';
764
+ import { ClientCredentialsProvider, refreshThenLogin } from '@mcp-abap-adt/auth-providers';
765
+
766
+ const clientCredentials: TokenProviderFactory = (destination, authConfig) => {
767
+ if (!authConfig) throw new Error(`No client for ${destination}`);
768
+ return new ClientCredentialsProvider({
769
+ uaaUrl: authConfig.uaaUrl,
770
+ clientId: authConfig.uaaClientId,
771
+ clientSecret: authConfig.uaaClientSecret,
772
+ renewal: refreshThenLogin(), // yours: the broker gives a provider of yours none
773
+ // No persistence: the token API writes every answer of this provider itself.
774
+ });
775
+ };
776
+
777
+ const tokenBroker = new AuthBroker({
778
+ serviceKeyStore: myKeyStore,
779
+ sessionStore: mySessionStore,
780
+ provider: clientCredentials,
781
+ onWriteFailure: 'fail', // the token API writes every answer
782
+ });
783
+ ```
784
+
785
+ **The factory is handed the means and the client, never a stored secret.** `authConfig` is the
786
+ client (`uaaUrl`, `uaaClientId`, `uaaClientSecret` — the session store's when it answers one,
787
+ else the key store's; `refreshToken` present as a key, never a value), or `null` when no store
788
+ has a client. `connConfig` is `serviceUrl` (the session's, else the key store's; neither is an
789
+ error before the factory is called), `sapClient`, `language`, `authType`, `grantType` — no
790
+ token, cookies, expiry, refresh token or binding. The broker cannot know what your factory
791
+ composes from what it is handed, so the consumer path is **never seeded from the store**: a
792
+ provider of yours that must resume after a restart composes that itself — its own seed and its
793
+ own persistence (`refreshStatePersistence` over a store of yours) — or use the row path, whose
794
+ providers the broker seeds from a matching record.
795
+
796
+ **An instance after the means changed** cannot be rebuilt, and the identity of the credential it
797
+ holds is unknown to the broker: once the identity the instance was first used for changes, the
798
+ token API refuses the destination — `DestinationConfigError(['provider'])`, "the destination's
799
+ means changed since the provider instance was first used for it" — until a new broker.
800
+
801
+ **A destination stated `basic` or `snc` has no token API**: the token API reads the
802
+ destination's `authType` first and throws `DestinationConfigError` naming `authType` before any
803
+ provider is asked. A key store that states no `authType`, or no key store, is served by a
804
+ `provider` of yours as before.
805
+
806
+ **Two token sources, with a `provider` option.** `getProvider` never uses the `provider` option.
807
+ A process that gives the broker a `provider` and also calls `getProvider` for the same
808
+ destination has two providers for it — each with its own token, renewal and binding record,
809
+ both writing the same session. Use one path per destination; or, to hand your own provider to a
810
+ connector, wrap the token API: `TokenAuthProvider.from(broker.createTokenRefresher('TRIAL'))`
879
811
  (`@mcp-abap-adt/auth-providers`), whose every renewal the token API writes.
880
812
 
881
- ### Creating Token Refresher for DI
813
+ ### Creating a Token Refresher for DI
882
814
 
883
- `createTokenRefresher(destination)` returns an `ITokenRefresher` (from
815
+ `createTokenRefresher(destination, { signal? })` returns an `ITokenRefresher` (from
884
816
  `@mcp-abap-adt/interfaces-auth`) bound to one destination — `getToken()` is
885
- `broker.getToken(destination)`, `refreshToken()` is
886
- `broker.refreshToken(destination)` — for a connection of your own that asks
887
- for a token per request and for a new one after a 401. It is unchanged since
888
- 3.x and not deprecated: calm-server injects it into its own connection.
817
+ `broker.getToken(destination, { signal })`, `refreshToken()` is
818
+ `broker.refreshToken(destination, { signal })` — for a connection of your own that asks for a
819
+ token per request and for a new one after a 401.
889
820
 
890
821
  ```typescript
891
- const tokenRefresher = broker.createTokenRefresher('TRIAL');
822
+ const tokenRefresher = broker.createTokenRefresher('TRIAL', { signal: session.signal });
892
823
 
893
- // In your own connection:
894
- const token = await tokenRefresher.getToken();
824
+ const current = await tokenRefresher.getToken();
895
825
  // …the server answered 401:
896
- const newToken = await tokenRefresher.refreshToken();
826
+ const renewed = await tokenRefresher.refreshToken();
897
827
  ```
898
828
 
899
- A `@mcp-abap-adt/connection` 10 connector takes an `IAuthProvider` instead:
900
- give it `await broker.getProvider('TRIAL')`.
901
-
902
- ## Migrating from 4.0.0
903
-
904
- 4.1.0 is a minor: nothing to change to keep 4.0.0's behaviour. Without a
905
- `clientAuthentication` strategy every destination is served as before and
906
- nothing certificate-related is read. What a 4.0.0 consumer may still notice:
907
-
908
- 1. **A refusal's message gains a hint.** A UAA, OIDC or `saml2_bearer`
909
- destination whose key store answers no client (or one without a client
910
- id) is still a `DestinationConfigError` with the same `missingFields`; its
911
- message now ends with `; a certificate client needs a clientAuthentication
912
- strategy`. Match on `missingFields` or the class, never the message.
913
- 2. **Dependencies.** `@mcp-abap-adt/interfaces-auth-broker` `^1.2.0` (the
914
- optional `IServiceKeyStore.getClientCertificate` and `IClientCertificate`),
915
- `@mcp-abap-adt/interfaces-auth` `^3.2.0` and `@mcp-abap-adt/auth-providers`
916
- `^5.3.0` (`IClientAuthentication`). A key store that reads x509 keys is
917
- auth-stores `^3.3.0`; an older store simply has no certificate client.
918
- 3. **New exports:** `fromServiceKeyCertificate`, `fromServiceKeySecret`, and
919
- the types `ClientAuthenticationStrategy`, `ClientAuthenticationContext`,
920
- `ClientAuthenticationGrant`, `FromServiceKeySecretOptions`,
921
- `TokenProviderClient`, `IClientCertificate`, `IClientAuthentication`.
922
-
923
- **Adopting a strategy** (`clientAuthentication`, see *How the Client
924
- Authenticates*) changes, for the destinations it applies to:
925
-
926
- - **No secret goes to the provider** — the strategy's answer does; a
927
- provider's constructor refuses both.
928
- - **A secret is bound to the client's identity**: a secret key and an x509
929
- key of the same client id at the same `uaaUrl` share one session, so
930
- switching between them keeps the token and the refresh token; a destination
931
- stating no parseable `serviceUrl` reuses a session stored without
932
- `issuedFor`, which 4.0.0 never did.
933
- - **The token API with a factory of yours** passes the fourth argument; seeds
934
- your factory with a stored refresh token, token or cookies only when the
935
- session is bound here (4.0.0 carried the stored refresh token over whatever
936
- its binding); refuses a destination that states no `authType` / `grantType`;
937
- and turns a throwing factory into a `DestinationConfigError` naming
938
- `provider`, `clientAuthentication`.
939
-
940
- ## Migrating from 3.x
941
-
942
- 4.0.0 is a major: what a 3.x consumer must do, in short (the full list is the
943
- [CHANGELOG](CHANGELOG.md)'s 4.0.0 entry).
944
-
945
- 1. **Session store: `@mcp-abap-adt/auth-stores` 3 or later** (`^3.1.0` for
946
- the binding), or one of your own on the
947
- `@mcp-abap-adt/interfaces-auth-broker` contract. The broker writes the
948
- secret alone — token or cookies, `expiresAt`, refresh token, `issuedFor`,
949
- `issuedBy` — and auth-stores 1.x/2.x's session stores refuse such a write
950
- (their `AbapSessionStore` throws without `serviceUrl`): they are not
951
- supported. A custom store must keep `issuedFor` and `issuedBy`.
952
- 2. **State the means in a key store**, not in the session: `authType`, and
953
- for `jwt` / `saml` a `grantType`; the client; the URL. For a SAP service
954
- key, `new AbapServiceKeyStore(dir, { grantType: 'authorization_code' })`
955
- (or `XsuaaServiceKeyStore`); otherwise `EnvDestinationStore(dir, {
956
- fallback })`. A 3.x session file is a readable destination as it is once
957
- it states `SAP_AUTH_TYPE` (and `SAP_GRANT_TYPE` for `jwt` / `saml`).
958
- 3. **A connector of `@mcp-abap-adt/connection` 10** takes `await
959
- broker.getProvider(destination)` — no `getToken` before connecting, no
960
- token refresher. Give the broker the collaborators its grants need
961
- (`authorization`, `oidcAuthorization`, `deviceCodePresenter`,
962
- `samlCookies`, `assertionReplayStore`); there is no default.
963
- 4. **The token API** keeps its calls, signatures and 3.x reads with a
964
- `provider` of yours; it writes the secret alone, refuses a destination
965
- stated `basic` or `snc`, and throws a failed write while the broker retries
966
- it.
967
- 5. **`getConnectionConfig` / `getAuthorizationConfig`** compose the key
968
- store's means with the session's secret; a URL or client kept only in the
969
- session store is not found any more.
970
- 6. **New:** catch `DestinationConfigError`; call `flush()` on shutdown;
971
- write `bindingOf(means)` beside a credential you hand over yourself.
972
- 7. **Contracts:** `interfaces-auth` 3, `interfaces-auth-sap` 2,
973
- `interfaces-auth-broker` 1.1 (the store contracts moved there from
974
- `interfaces-auth-sap`), auth-providers 5. The commands are
975
- `@mcp-abap-adt/auth-broker-cli` 2.0.0.
976
-
977
- ## Migrating from 2.2.0
978
-
979
- 1. `tokenProvider` is now `provider`, and the `browser` argument is gone:
980
- `new AuthBroker({ sessionStore, serviceKeyStore, tokenProvider }, 'system', logger)`
981
- becomes `new AuthBroker({ sessionStore, serviceKeyStore, provider }, logger)`.
982
- 2. The provider must implement `IRefreshableTokenProvider` (`refreshTokens()`,
983
- a new token, never the cached one) — `@mcp-abap-adt/auth-providers` 4.2.0
984
- providers do. Pass a factory instead of an instance to have the broker seed
985
- it with the stored refresh token and token.
986
- 3. `allowBrowserAuth: false` and `BROWSER_AUTH_REQUIRED` are gone: give the
987
- provider an authorization strategy that refuses and catch your own error
988
- (see *Headless Processes*). A browser login that fails — timeout, the
989
- identity provider's refusal, a busy callback port — is auth-providers'
990
- `BrowserAuthError` (from 4.2.0).
991
- 4. Provider errors arrive unchanged: match on the class or `code`, not on the
992
- old `Token provider … error for <destination>` messages.
993
- 5. The broker no longer writes the client secret into the session store. Read
994
- it from the service key store if you relied on finding it in the session.
995
- 6. `refreshToken()` now forces a new token; it used to return `getToken()`'s.
996
- 7. Node.js 22, 24 or 26; SAML runs need the IdP trust (see *Migrating `mcp-sso`
997
- SAML runs from 2.2.0* in the [CLI's README](../auth-broker-cli/README.md)).
829
+ A `@mcp-abap-adt/connection` 14 connector takes an `IAuthProvider` instead: give it `await
830
+ broker.getProvider('TRIAL', { signal })`.
998
831
 
999
- ## Configuration
832
+ ## Errors
1000
833
 
1001
- ### Environment Variables
834
+ **A provider's failure passes as the same object.** Whatever a provider throws from
835
+ `getTokens()` / `refreshTokens()` — an `AuthProviderFailure` of `@mcp-abap-adt/auth-errors` —
836
+ `getToken()` / `refreshToken()` rethrow unchanged, so its kind, facts, words and diagnostics are
837
+ the provider's; the moments of a provider `getProvider` hands out are never wrapped. Read a
838
+ failure with auth-errors, never by class or message:
1002
839
 
1003
- The library reads no environment variable: the stores take their directories
1004
- from their constructors, and the providers their settings from the
1005
- destination's means and the collaborators you give the broker. (In the
1006
- repository's test suites, `DEBUG_BROKER=true` — or `DEBUG_AUTH_BROKER=true`,
1007
- `DEBUG=broker` — turns on the test logger, its level from `AUTH_LOG_LEVEL`.)
840
+ ```typescript
841
+ import { isDestinationConfigError } from '@mcp-abap-adt/auth-broker';
842
+ import { isAuthProviderFailure, readFailure } from '@mcp-abap-adt/auth-errors';
1008
843
 
1009
- ### Logging
844
+ try {
845
+ await broker.getToken('TRIAL', { signal });
846
+ } catch (error) {
847
+ if (isDestinationConfigError(error)) {
848
+ // Names only: error.missingFields, e.g. ['renewal'] or ['uaaClientSecret'].
849
+ logger.error(`${error.destination} lacks: ${error.missingFields.join(', ')}`);
850
+ } else if (isAuthProviderFailure(error)) {
851
+ const failure = readFailure(error, 'token-source');
852
+ // failure.kind: 'interactive-login', 'credential-refused', 'request-failed', …
853
+ logger.error(failure.hint ? `${failure.reason} — ${failure.hint}` : failure.reason);
854
+ }
855
+ throw error;
856
+ }
857
+ ```
1010
858
 
1011
- Pass an `ILogger` as the constructor's second argument (for instance
1012
- `DefaultLogger` from `@mcp-abap-adt/logger`); without one, nothing is logged.
859
+ `readFailure(thrown, operation)` gives the same kind and facts for a failure of another
860
+ installed copy of auth-errors (its diagnostics dropped); `matchKind(failure, handlers)` switches
861
+ over every kind. auth-providers' *Migrating to 6.0.0* maps each 5.x error class to its kind.
1013
862
 
1014
- **What is logged:** the broker's initialization (whether a key store is
1015
- given; the `provider` option: `none`, `factory` or `instance`), each provider build (the destination's `authType` and grant, whether
1016
- it was seeded), each session secret saved (token or cookies, whether a
1017
- refresh token came back, the expiry), a stored secret discarded because it is
1018
- bound elsewhere (the destination only), and each failed write with the error's
1019
- class name.
863
+ **The failures the broker makes itself** are `AuthProviderFailure`s minted by auth-errors, no
864
+ free words of its own:
1020
865
 
1021
- **What is never logged:** any part of a token, refresh token, password or
1022
- secret — not a prefix, not a suffix — nor a store error's message, which may
1023
- quote what was being written.
866
+ | When | Kind and facts | Words |
867
+ |---|---|---|
868
+ | a caller's signal aborted | `interactive-login`, `outcome: 'aborted'` | the authorization was aborted |
869
+ | a session write did not land, under `'fail'` (the consumer path; the row path's provider answers the same) | `unknown`, `operation: 'persisting-tokens'`, an allowlisted `code` | persisting the tokens failed (unknown error[, CODE]) |
870
+ | a provider answered no token | `request-failed`, `operation: 'token-source'`, `problem: 'no-access-token'` | the token source returned no access_token |
871
+
872
+ **`DestinationConfigError`** — a destination that lacks what its type needs, thrown by
873
+ `getProvider` (and the token API) before any provider is asked. It carries `name:
874
+ 'DestinationConfigError'`, `code: 'DESTINATION_CONFIG'`, `destination` and `missingFields` —
875
+ store field or broker option names only, never a value — and, when a provider's or a strategy's
876
+ failure caused the refusal, `error`: that failure as auth-errors read it (`IAuthProviderError`:
877
+ kind, facts, rendered `reason` and `hint`, admitted diagnostics — no `cause`, no message of any
878
+ thrown value). Its message is `Destination "<destination>": <reason> (<fields>)`, the reason
879
+ followed by `: <error.reason>` when `error` is present; `error.hint` is not in the message.
880
+ Recognise it with `isDestinationConfigError(value)` — structural, so a JSON copy and one of
881
+ another installed copy of this package answer true — rather than `instanceof`.
882
+
883
+ | Case | `missingFields` |
884
+ |---|---|
885
+ | no `serviceKeyStore` option (`getProvider`) | `serviceKeyStore` |
886
+ | the token API with neither a `provider` nor a `serviceKeyStore` option | `provider`, `serviceKeyStore` |
887
+ | the token API on a destination stated `basic` or `snc` | `authType` |
888
+ | the token API without a `provider` option, on a `jwt` / `none` or `saml` / `none` destination | `provider` |
889
+ | a token row built with no `renewal` option | `renewal` (beside every other missing field) |
890
+ | a destination that writes a secret (a token row, or the token API with a `provider`) and no `onWriteFailure` option (`'fail'` / `'continue'`) | `onWriteFailure` |
891
+ | the `renewal` option threw (`error` carried) | `renewal` |
892
+ | a provider's constructor refused the configuration the row gave it (`error` carried) | the store fields its configuration facts name, or none |
893
+ | the token API with an instance `provider` after the destination's identity changed | `provider` |
894
+ | the key store has no means for the destination — whatever the session holds | `authType` |
895
+ | no `authType`, `''`, or one that is not `basic`, `jwt`, `saml`, `snc` | `authType` |
896
+ | `jwt` / `saml` without `grantType`, `''`, or a pair outside the table | `grantType` |
897
+ | `basic` without user or password (`''` counts as missing) | `username`, `password` — each that is missing |
898
+ | `snc` without `sncPartnerName` | `sncPartnerName` |
899
+ | `snc` whose settings the provider refuses (`error` carried) | the store field its facts name, e.g. `sncQop`; for another kind, the SNC fields the means state |
900
+ | `jwt` / `none` without a token in the session | `authorizationToken` |
901
+ | `saml` / `none` without cookies in the session | `sessionCookies` |
902
+ | `jwt` / `none`, `saml` / `none` whose stored `issuedFor` is not the destination's resource, or is absent, or the means state no `serviceUrl` | `issuedFor` |
903
+ | `jwt` / `none`, `saml` / `none` whose stored `issuedBy` is not exactly the row's record (a 4.x binding included) | `issuedBy` |
904
+ | 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 |
905
+ | `authorization_code` / `passcode` without the `authorization` option | `authorization` |
906
+ | an OIDC grant without its client's id in the key store | `uaaClientId` |
907
+ | an OIDC grant with neither `oidcIssuerUrl` nor every endpoint its row reads | `oidcIssuerUrl`, and each endpoint missing |
908
+ | `oidcScopes` that is not a list of strings; `oidcActorTokenType` that is not a string | `oidcScopes`, `oidcActorTokenType` |
909
+ | `password` without user or password | `username`, `password` — each that is missing |
910
+ | `token_exchange` without its subject | `oidcSubjectToken`, `oidcSubjectTokenType` — each that is missing |
911
+ | `oidc_authorization_code` without the `oidcAuthorization` option | `oidcAuthorization` |
912
+ | `device_code` without the `deviceCodePresenter` option | `deviceCodePresenter` |
913
+ | a SAML grant without its trust (`''` and an empty certificate list count as missing) | `samlIdpSsoUrl`, `samlSpEntityId`, `samlIdpEntityId`, `samlIdpCertificates` — each that is missing |
914
+ | `samlIdpInitiated` that is not a boolean | `samlIdpInitiated` |
915
+ | `saml2_bearer` without its client | `uaaUrl`, `uaaClientId` — each that is missing |
916
+ | a client row (UAA, OIDC, `saml2_bearer`) with no client id and no `clientAuthentication` strategy | as above; the message adds `a certificate client needs a clientAuthentication strategy` |
917
+ | the `clientAuthentication` strategy threw, refused, or answered no client authentication; the certificate client could not be read | `clientAuthentication` (see *How the Client Authenticates*) |
918
+ | the token API with a `clientAuthentication` strategy and a factory, on a destination that states no `jwt` / `saml` type | `authType`, `grantType` |
919
+ | the token API's factory threw beside a `clientAuthentication` strategy | `provider`, `clientAuthentication` |
920
+ | a SAML grant without its collaborators | `authorization`, `samlCookies` (`saml2_pure`), `assertionReplayStore` — each that is missing |
921
+ | a certificate the validator cannot read (`error` carried) | `samlIdpCertificates` |
922
+ | a `samlClockSkewMs` that is not a whole, non-negative number | `samlClockSkewMs` |
923
+ | a store answered a field in a shape the broker cannot take (a function, a getter that throws) | that field |
924
+
925
+ Kept as the broker's own configuration words, none copying a provider's: the reason of every row
926
+ above, "the destination has no client certificate / secret", and the hint `a certificate client
927
+ needs a clientAuthentication strategy`.
928
+
929
+ **What is not an auth failure:**
930
+
931
+ - **A store's read failure** (anything but `FILE_NOT_FOUND`, which is absence) reaches the
932
+ caller as the store raised it — your collaborator's error, returned to you, also through the
933
+ shared resolution. A store that answers `null`, or fails with `FILE_NOT_FOUND` (logged at
934
+ debug), means "nothing here".
935
+ - **The constructor's argument checks** (`AuthBroker: sessionStore is required`, …) are plain
936
+ `Error`s naming option names: a programming error.
937
+ - **The token API with a factory and no `serviceUrl`** in either store: `Session for destination
938
+ "<name>" is missing required field 'serviceUrl'`, a plain `Error`, before the factory is
939
+ called.
940
+ - **`flush()`** rejects with the `AggregateError` of `SessionWriteFailure`s above.
941
+
942
+ ## Logging and `authDebug`
943
+
944
+ Pass an `ILogger` (`@mcp-abap-adt/interfaces-utils`) as the constructor's second argument;
945
+ without one nothing is logged. Every line goes through a guard: a logger that throws or rejects
946
+ changes no outcome. The broker never writes to stdout.
947
+
948
+ **What is logged:** `debug` — the broker's initialization (whether a key store is given; the
949
+ `provider` option: `none`, `factory` or `instance`), each provider build (`{ authType, grant,
950
+ seeded }`), the token API's method, a replaced provider's write dropped, a destination whose
951
+ means never let a stored secret be used; `info` — each session secret saved (`{ credential:
952
+ 'token' | 'cookies', hasRefreshToken, expiresAt }`), a refresh token cleared; `warn` — a stored
953
+ secret not recorded under the current means and not used, a session write that failed (`Session
954
+ write for <destination> failed; it stays pending until the destination's next write or
955
+ flush()`, with the failure's `logFields`), a write not made because the destination is now
956
+ `basic` / `snc`. The destination name is the only free value.
957
+
958
+ **What is never logged:** any part of a token, refresh token, password or secret, a store's or a
959
+ provider's message, a URL, a client id, `state`.
960
+
961
+ After a login, at `info`, your logger is called with the message `[AuthBroker] Session secret
962
+ saved for TRIAL` and the meta `{ credential: 'token', hasRefreshToken: true, expiresAt:
963
+ 1790000000000 }`.
964
+
965
+ **`authDebug`.** `authDebug: true` is passed to every token provider the broker builds; on only
966
+ for `true` itself — absent, `false`, `'true'` or `1` is off — and **never read from the
967
+ environment** (no `DEBUG_*` variable changes anything). The broker's logger is the providers'
968
+ logger, so the provider's one debug line for a refused token request lands in yours. With
969
+ `authDebug` that line names the request's secrets in the provider's prepared form (the first and
970
+ last four characters around a length marker) and never the server's text; without it no line
971
+ carries a secret or server text. A `provider` of yours — an instance or what a factory builds —
972
+ keeps its own setting.
1024
973
 
1025
- With `DefaultLogger` at `info`:
974
+ ## Configuration
1026
975
 
1027
- ```
1028
- [INFO] ℹ️ [AuthBroker] Session secret saved for TRIAL
1029
- {"credential":"token","hasRefreshToken":true,"expiresAt":1790000000000}
1030
- ```
976
+ ### Environment Variables
977
+
978
+ The library reads no environment variable: the stores take their directories from their
979
+ constructors, the providers their settings from the destination's means and the collaborators
980
+ you give the broker, and `authDebug` is an option. (In the repository's test suites,
981
+ `DEBUG_BROKER=true` — or `DEBUG_AUTH_BROKER=true`, `DEBUG=broker` — turns on the test logger,
982
+ its level from `AUTH_LOG_LEVEL`.)
1031
983
 
1032
984
  ### File Structure
1033
985
 
1034
- With `@mcp-abap-adt/auth-stores` 3 one `<destination>.env` file can hold a
1035
- destination whole, each store reading and writing only its own keys — the
1036
- means through `EnvDestinationStore`, the secret through `AbapSessionStore`
1037
- (or both in separate directories, as you compose them):
986
+ With `@mcp-abap-adt/auth-stores` 4 one `<destination>.env` file can hold a destination whole,
987
+ each store reading and writing only its own keys — the means through `EnvDestinationStore`, the
988
+ secret through `AbapSessionStore` (or both in separate directories, as you compose them):
1038
989
 
1039
990
  ```env
1040
991
  # The means — EnvDestinationStore (ABAP_DESTINATION_VARS); the broker never writes them
@@ -1051,24 +1002,23 @@ SAP_JWT_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
1051
1002
  SAP_EXPIRES_AT=1790000000000
1052
1003
  SAP_REFRESH_TOKEN=refresh_token_string
1053
1004
  SAP_ISSUED_FOR=https://your-system.abap.us10.hana.ondemand.com:443?sap-client=100
1054
- SAP_ISSUED_BY=https://your-account.authentication.us10.hana.ondemand.com:443?client_id=client_id
1005
+ SAP_ISSUED_BY=mcp-abap-adt-binding/2;jwt/authorization_code;client_id;https%3A%2F%2Fyour-account.authentication.us10.hana.ondemand.com;;;;;;;;;;8be36724f0e889f23f88526620417e7545eed1115742c715e2bc8da01678d785
1055
1006
  ```
1056
1007
 
1057
1008
  A client that authenticates with a certificate states `SAP_UAA_CLIENT_CERT_PATH`,
1058
- `SAP_UAA_CLIENT_KEY_PATH` and `SAP_UAA_CERT_URL` — paths and a URL, never PEM —
1059
- in place of `SAP_UAA_CLIENT_SECRET` (see *How the Client Authenticates*).
1060
- SAML cookies are `SAP_SESSION_COOKIES_B64` in place of `SAP_JWT_TOKEN`. The
1061
- XSUAA stores use the same names with `XSUAA_` (`XSUAA_DESTINATION_VARS`,
1062
- `XSUAA_SESSION_VARS`; the URL is `XSUAA_MCP_URL`). Every means key
1063
- (`SAP_USERNAME`, `SAP_SNC_*`, `SAP_OIDC_*`, `SAP_SAML_*`, …) is listed in
1009
+ `SAP_UAA_CLIENT_KEY_PATH` and `SAP_UAA_CERT_URL` — paths and a URL, never PEM — in place of
1010
+ `SAP_UAA_CLIENT_SECRET`. SAML cookies are `SAP_SESSION_COOKIES_B64` in place of
1011
+ `SAP_JWT_TOKEN`. The XSUAA stores use the same names with `XSUAA_` (`XSUAA_DESTINATION_VARS`,
1012
+ `XSUAA_SESSION_VARS`; the URL is `XSUAA_MCP_URL`). Every means key (`SAP_USERNAME`,
1013
+ `SAP_SNC_*`, `SAP_OIDC_*`, `SAP_SAML_*`, …) is listed in
1064
1014
  [USAGE.md](../../docs/using/USAGE.md#environment-variables).
1065
1015
 
1066
1016
  #### Service Key File (`{destination}.json`)
1067
1017
 
1068
- A SAP service key, read by `AbapServiceKeyStore` (an ABAP environment key:
1069
- the client and the ABAP URL) or `XsuaaServiceKeyStore` (an XSUAA key: the
1070
- client). A key cannot state which grant the destination uses, so the store is
1071
- told: `new AbapServiceKeyStore(dir, { grantType: 'authorization_code' })`.
1018
+ A SAP service key, read by `AbapServiceKeyStore` (an ABAP environment key: the client and the
1019
+ ABAP URL) or `XsuaaServiceKeyStore` (an XSUAA key: the client). A key cannot state which grant
1020
+ the destination uses, so the store is told: `new AbapServiceKeyStore(dir, { grantType:
1021
+ 'authorization_code' })`.
1072
1022
 
1073
1023
  ```json
1074
1024
  {
@@ -1081,13 +1031,11 @@ told: `new AbapServiceKeyStore(dir, { grantType: 'authorization_code' })`.
1081
1031
  }
1082
1032
  ```
1083
1033
 
1084
- An XSUAA key carries no URL of the resource it authorizes for: state it as
1085
- means in an `EnvDestinationStore` (`XSUAA_MCP_URL`, with
1086
- `XSUAA_DESTINATION_VARS`) whose `fallback` is the `XsuaaServiceKeyStore`.
1087
-
1088
- An x509 XSUAA key (created with `{"credential-type": "x509"}`) holds a
1034
+ An XSUAA key carries no URL of the resource it authorizes for: state it as means in an
1035
+ `EnvDestinationStore` (`XSUAA_MCP_URL`, with `XSUAA_DESTINATION_VARS`) whose `fallback` is the
1036
+ `XsuaaServiceKeyStore`. An x509 XSUAA key (created with `{"credential-type": "x509"}`) holds a
1089
1037
  certificate client instead of a secret — the broker uses it only beside a
1090
- `clientAuthentication` strategy (see *How the Client Authenticates*):
1038
+ `clientAuthentication` strategy:
1091
1039
 
1092
1040
  ```json
1093
1041
  {
@@ -1100,401 +1048,200 @@ certificate client instead of a secret — the broker uses it only beside a
1100
1048
  }
1101
1049
  ```
1102
1050
 
1103
- ## Responsibilities and Design Principles
1104
-
1105
- ### Core Development Principle
1106
-
1107
- **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**.
1108
-
1109
- This means:
1110
- - 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`
1111
- - Does not know about internal data structures or methods not defined in interfaces
1112
- - Does not make assumptions about implementation behavior beyond interface contracts
1113
- - Does not access properties or methods not explicitly defined in interfaces
1114
-
1115
- This principle ensures:
1116
- - **Loose coupling**: `AuthBroker` is decoupled from concrete implementations
1117
- - **Flexibility**: New implementations can be added without modifying `AuthBroker`
1118
- - **Testability**: Easy to mock dependencies for testing
1119
- - **Maintainability**: Changes to implementations don't affect `AuthBroker`
1120
-
1121
- ### Package Responsibilities
1051
+ ## Responsibilities
1122
1052
 
1123
- The `@mcp-abap-adt/auth-broker` package defines **interfaces** and provides **orchestration logic** for authentication. It does **not** implement concrete storage or token acquisition mechanisms - these are provided by separate packages (`@mcp-abap-adt/auth-stores`, `@mcp-abap-adt/auth-providers`).
1053
+ **The broker** reads each store for its role; builds one provider per destination and path — the
1054
+ one the destination states, or yours — and rebuilds it when what it was built from changes; asks
1055
+ it once (`getTokens()` for `getToken()`, `refreshTokens()` for `refreshToken()`) with no retry of
1056
+ its own; writes what it obtains, the secret alone with its binding, one write at a time per
1057
+ destination. It works through the contracts only — `IServiceKeyStore`, `ISessionStore`,
1058
+ `IAuthProvider`, `IRefreshableTokenProvider` — and constructs auth-providers' classes by name
1059
+ only to build the one a destination states. It never imports `@mcp-abap-adt/auth-stores`.
1124
1060
 
1125
- #### What AuthBroker Does
1061
+ **It does not** implement storage (the stores do), token acquisition, refresh or the token's
1062
+ validity (the providers do), how a login is conducted (the strategies you give), when to renew
1063
+ (the renewal strategy you give), or what a failed write means (your `onWriteFailure`). It copies
1064
+ no secret: the client secret stays in the key store, and the broker never writes the key store
1065
+ (its contract has no write method).
1126
1066
 
1127
- - **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)
1128
- - **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
1129
- - **Asks the provider once**: `getTokens()` for `getToken()`, `refreshTokens()` for `refreshToken()` — no retries, no fallbacks
1130
- - **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
1131
- - **Works with interfaces only**: `IServiceKeyStore`, `ISessionStore`, `IAuthProvider`, `IRefreshableTokenProvider`
1132
-
1133
- #### What AuthBroker Does NOT Do
1134
-
1135
- - **Does NOT implement storage**: File I/O, parsing, and storage logic are handled by concrete store implementations from `@mcp-abap-adt/auth-stores`
1136
- - **Does NOT implement token acquisition**: OAuth2 flows, refresh token logic, and client credentials are handled by concrete provider implementations from `@mcp-abap-adt/auth-providers`
1137
- - **Does NOT judge the token**: whether a cached token is still valid, and whether to refresh or log in, is the provider's decision
1138
- - **Does NOT decide how a login is conducted**: browser, headless or pasted code is the provider's authorization strategy
1139
- - **Does NOT copy secrets**: the client secret stays in the service key store
1140
-
1141
- ### Consumer Responsibilities
1142
-
1143
- The **consumer** (application using `AuthBroker`) is responsible for:
1144
-
1145
- 1. **Composing the stores and collaborators**: an `IServiceKeyStore` for the means and an `ISessionStore` for the secret — with auth-stores 3:
1146
- - **ABAP systems (a SAP service key)**: `AbapServiceKeyStore(dir, { grantType })`, alone or as the `fallback` of an `EnvDestinationStore`, and `AbapSessionStore` (or `SafeAbapSessionStore`, in memory)
1147
- - **Destinations without a key** (basic, SNC, OIDC, SAML, `none`): `EnvDestinationStore(dir)` and `AbapSessionStore`
1148
- - **XSUAA services**: `EnvDestinationStore(dir, { variables: XSUAA_DESTINATION_VARS, fallback: new XsuaaServiceKeyStore(keys, { grantType }) })` and `XsuaaSessionStore` (or `SafeXsuaaSessionStore`)
1149
- - the collaborator options the destinations' grants need — or, for the token API the 3.x way, an `IRefreshableTokenProvider` of your own
1150
-
1151
- 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.
1152
-
1153
- 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`.
1154
-
1155
- ### Store Responsibilities
1156
-
1157
- Concrete `ISessionStore` implementations are responsible for:
1158
-
1159
- - **Handling their own data format**: Each store knows its internal data format (e.g., `AbapSessionData`, `BtpBaseSessionData`)
1160
- - **Converting between formats**: Converting between `IConfig`/`IConnectionConfig` and internal storage format
1161
- - **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)
1162
-
1163
- ### Provider Responsibilities
1164
-
1165
- Concrete `IRefreshableTokenProvider` implementations are responsible for:
1166
-
1167
- - **Obtaining tokens**: Using OAuth2 flows, refresh tokens, or client credentials to obtain JWT tokens
1168
- - **Managing token lifecycle**: Caching, validating, refreshing, and re-authenticating as needed (`getTokens()`)
1169
- - **Forcing a new token**: `refreshTokens()` — never the cached one
1170
- - **Reporting failures with typed errors**, which the broker passes on unchanged
1171
-
1172
- ### Design Principles
1173
-
1174
- 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)
1175
- 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`
1176
- 3. **Single Responsibility**: Each component has a single, well-defined responsibility:
1177
- - `AuthBroker`: Orchestration — resolving, asking, persisting
1178
- - `ISessionStore`: Session data storage and retrieval
1179
- - `IRefreshableTokenProvider`: Token acquisition and lifecycle
1180
- - `IServiceKeyStore`: Service key storage and retrieval
1181
- 4. **Interface Segregation**: Interfaces are focused and minimal, containing only what's necessary for their specific purpose
1182
- 5. **Open/Closed Principle**: New store and provider implementations can be added without modifying `AuthBroker`
1067
+ **You** compose the stores, the collaborators, `renewal` and `onWriteFailure`; state the means in
1068
+ the key store; and give a session store that keeps `issuedFor` / `issuedBy` exactly, takes
1069
+ `refreshToken: ''` as clearing, and settles every `saveSession`.
1183
1070
 
1184
1071
  ## API
1185
1072
 
1186
- ### `AuthBroker`
1187
-
1188
- #### Constructor
1189
-
1190
- ```typescript
1191
- new AuthBroker(
1192
- config: {
1193
- sessionStore: ISessionStore; // required
1194
- serviceKeyStore?: IServiceKeyStore; // getProvider needs it
1195
- provider?: // the token API's, optional
1196
- | IRefreshableTokenProvider
1197
- | ((
1198
- destination: string,
1199
- authConfig: IAuthorizationConfig | null,
1200
- connConfig: IConnectionConfig,
1201
- client?: TokenProviderClient, // only beside clientAuthentication (4.1.0)
1202
- ) => IRefreshableTokenProvider);
1203
- // Collaborators, each a function of the destination, called once per
1204
- // build, never disposed by the broker:
1205
- authorization?: (destination: string, grant: StrategyGrant) => IAuthorizationStrategy<string>; // authorization_code, passcode, saml2_pure, saml2_bearer
1206
- oidcAuthorization?: (destination: string) => IAuthorizationStrategy<OidcCallbackResult>; // oidc_authorization_code
1207
- deviceCodePresenter?: (destination: string) => IDeviceCodePresenter; // device_code
1208
- samlCookies?: (destination: string) => (samlResponse: string) => Promise<string>; // saml2_pure
1209
- assertionReplayStore?: (destination: string) => IAssertionReplayStore; // saml2_pure, saml2_bearer
1210
- // How a client authenticates (4.1.0): every grant that authenticates one
1211
- clientAuthentication?: ClientAuthenticationStrategy;
1212
- },
1213
- logger?: ILogger,
1214
- )
1215
- ```
1216
-
1217
- **Parameters:**
1218
- - `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).
1219
- - `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.
1220
- - `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*); beside a `clientAuthentication` strategy, with a fourth argument (`TokenProviderClient`, see *How the Client Authenticates*). 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.
1221
- - `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*).
1222
- - `config.oidcAuthorization` - The interactive strategy of `jwt` / `oidc_authorization_code`.
1223
- - `config.deviceCodePresenter` - Where `jwt` / `device_code` shows the user the verification URL and code.
1224
- - `config.samlCookies` - `saml2_pure`: turns the validated SAMLResponse into the system's session cookies.
1225
- - `config.assertionReplayStore` - The replay store the SAML validators record each assertion in.
1226
- - `config.clientAuthentication` - How the client of every grant that authenticates one (the UAA and OIDC grants, `saml2_bearer`) authenticates to the authorization server: a `ClientAuthenticationStrategy` — `fromServiceKeyCertificate()`, `fromServiceKeySecret({ encoding })`, or your own composition. Called once per build; absent, the client secret as in 4.0.0 and nothing certificate-related is read (see *How the Client Authenticates*).
1227
- - 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.
1228
- - `logger` - Optional logger. If not provided, nothing is logged.
1229
-
1230
- **Available Implementations:**
1231
- - **Means** (auth-stores 3): `EnvDestinationStore(directory, { fallback? })`, `AbapServiceKeyStore(directory, { grantType?, log? })`, `XsuaaServiceKeyStore(directory, { grantType?, log? })`
1232
- - **Secret** (auth-stores 3): `AbapSessionStore(directory, log?)`, `SafeAbapSessionStore(log?)`, `XsuaaSessionStore(directory, log?)`, `SafeXsuaaSessionStore(log?)`, `EnvFileSessionStore(file, log?)`
1233
- - **Providers** (auth-providers 5): `AuthorizationCodeProvider(...)`, `ClientCredentialsProvider(...)` and the rest, for a `provider` of yours
1234
-
1235
- #### Methods
1236
-
1237
- ##### `getProvider(destination: string): Promise<IAuthProvider>`
1238
-
1239
- The `IAuthProvider` the destination states, built from the key store's means
1240
- and the session store's secret (see *A Provider for a Connector*). Cached per
1241
- destination; a failed build is retried on the next call. Throws
1242
- `DestinationConfigError` for a destination that lacks what its type needs, and
1243
- passes a store failure other than absence on as the store raised it.
1244
-
1245
- ##### `flush(): Promise<void>`
1246
-
1247
- Every session write still pending gets one more attempt. Resolves when all
1248
- have landed; rejects with an `AggregateError` naming the destinations whose
1249
- store still refuses (its `errors` carry each destination and the store
1250
- error's class only, never its message) — the broker keeps retrying them. See *Persistence and `flush()`*.
1251
-
1252
- ##### `getToken(destination: string): Promise<string>`
1253
-
1254
- 1. Reads the destination's `authType` from the key store: `basic` or `snc` is a `DestinationConfigError` naming `authType`, before any provider is asked.
1255
- 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.
1256
- 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.
1257
- 4. Throws the store's error if the write of this token failed (the broker keeps retrying it); else returns the token.
1258
-
1259
- ##### `refreshToken(destination: string): Promise<string>`
1260
-
1261
- The same, with `provider.refreshTokens()`: a new token, never the cached one —
1262
- for a caller whose token the server has just refused. A renewal already in
1263
- flight for the destination (a connector's, in `rejected()`) is joined.
1264
-
1265
- ##### `getAuthorizationConfig(destination)` / `getConnectionConfig(destination)`
1266
-
1267
- The two stores composed, each for its role:
1268
-
1269
- - `getConnectionConfig` — the service key store's means with the session's
1270
- secret (`authorizationToken`, `sessionCookies`, `expiresAt`) laid over them;
1271
- `null` when neither store holds anything.
1272
- - `getAuthorizationConfig` — the service key store's client (`uaaUrl`,
1273
- `uaaClientId`, `uaaClientSecret`) with the session's refresh token; `null`
1274
- when the key store has no client — an x509 key's included: it never answers
1275
- a certificate or a key.
1276
-
1277
- Means a session store answers (a client, a URL, an `authType`) are not read,
1278
- nor a secret a key store answers. Up to 3.1.0 both answered the session's
1279
- configuration whole, and the key's only when the session had none.
1280
-
1281
- ##### `createTokenRefresher(destination): ITokenRefresher`
1282
-
1283
- `getToken()` and `refreshToken()` bound to one destination, for injection into a connection of your own. Unchanged since 3.x; not deprecated.
1284
-
1285
- ##### Error Handling
1286
-
1287
- - **`DestinationConfigError`** — a destination that lacks what its type needs.
1288
- `getProvider` throws it before any provider is asked; so does the token
1289
- API, for the same destinations and for those below. It carries `code:
1290
- 'DESTINATION_CONFIG'`, `destination`, `missingFields` — field or option
1291
- names only. When a provider's constructor refused (an `sncQop` outside `1`,
1292
- `2`, `3`, `8`, `9`), the store field is named and the provider's own error
1293
- is not kept — its message quotes the value. **No stored value
1294
- reaches it**: not a password, not a token, not an `authType` that is none of
1295
- the four. Thrown for:
1296
-
1297
- | Case | `missingFields` |
1298
- |---|---|
1299
- | no `serviceKeyStore` option (`getProvider`) | `serviceKeyStore` |
1300
- | the token API with neither a `provider` nor a `serviceKeyStore` option | `provider`, `serviceKeyStore` |
1301
- | the token API on a destination stated `basic` or `snc` | `authType` |
1302
- | the token API without a `provider` option, on a `jwt` / `none` or `saml` / `none` destination | `provider` |
1303
- | the key store has no means for the destination — whatever the session holds | `authType` |
1304
- | no `authType`, `''`, or one that is not `basic`, `jwt`, `saml`, `snc` | `authType` |
1305
- | `jwt` / `saml` without `grantType`, `''`, or a pair outside the table | `grantType` |
1306
- | `basic` without user or password (`''` counts as missing) | `username`, `password` — each that is missing |
1307
- | `snc` without `sncPartnerName` | `sncPartnerName` |
1308
- | `snc` whose settings the provider refuses | the store field, e.g. `sncQop` |
1309
- | `jwt` / `none` without a token in the session | `authorizationToken` |
1310
- | `saml` / `none` without cookies in the session | `sessionCookies` |
1311
- | `jwt` / `none`, `saml` / `none` whose stored `issuedFor` is not the destination's resource, or is absent, or the means state no `serviceUrl` | `issuedFor` |
1312
- | `jwt` / `none`, `saml` / `none` whose means state an issuer and whose stored `issuedBy` is not it, or is absent | `issuedBy` |
1313
- | 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 |
1314
- | `authorization_code` / `passcode` without the `authorization` option | `authorization` |
1315
- | an OIDC grant without its client's id in the key store | `uaaClientId` |
1316
- | an OIDC grant with neither `oidcIssuerUrl` nor every endpoint its row reads | `oidcIssuerUrl`, and each endpoint missing: `oidcAuthorizationEndpoint` / `oidcDeviceAuthorizationEndpoint`, `oidcTokenEndpoint` |
1317
- | `password` without user or password | `username`, `password` — each that is missing |
1318
- | `token_exchange` without its subject | `oidcSubjectToken`, `oidcSubjectTokenType` — each that is missing |
1319
- | `oidc_authorization_code` without the `oidcAuthorization` option | `oidcAuthorization` |
1320
- | `device_code` without the `deviceCodePresenter` option | `deviceCodePresenter` |
1321
- | a SAML grant without its trust (`''` and an empty certificate list count as missing) | `samlIdpSsoUrl`, `samlSpEntityId`, `samlIdpEntityId`, `samlIdpCertificates` — each that is missing |
1322
- | `saml2_bearer` without its client | `uaaUrl`, `uaaClientId` — each that is missing |
1323
- | a client row (UAA, OIDC, `saml2_bearer`) with no client id and no `clientAuthentication` strategy | as above; the message adds `a certificate client needs a clientAuthentication strategy` |
1324
- | the `clientAuthentication` strategy threw, refused, or answered no client authentication; the certificate client could not be read | `clientAuthentication` (fixed words, see *How the Client Authenticates*) |
1325
- | the token API with a `clientAuthentication` strategy and a factory, on a destination that states no `jwt` / `saml` type | `authType`, `grantType` |
1326
- | the token API's factory threw beside a `clientAuthentication` strategy | `provider`, `clientAuthentication` |
1327
- | a SAML grant without its collaborators | `authorization`, `samlCookies` (`saml2_pure`), `assertionReplayStore` — each that is missing |
1328
- | a certificate the validator cannot read | `samlIdpCertificates` |
1329
- | a `samlClockSkewMs` that is not a whole, non-negative number | `samlClockSkewMs` |
1330
-
1331
- - **Provider errors propagate unchanged** — the same object, with its class,
1332
- `code`, `missingFields` and `cause`: auth-providers' `ValidationError`,
1333
- `BrowserAuthError`, `AssertionValidationError`, network errors (`ECONNREFUSED`,
1334
- `ETIMEDOUT`, `ENOTFOUND`), and whatever your authorization strategy throws.
1335
- The broker does not retry a failed call.
1336
- - **Store reads: absence is an answer, a failure is not.** A store that answers
1337
- `null`, or fails with `FILE_NOT_FOUND` (logged at debug), means "nothing
1338
- here", and the broker goes on to the next source. Any other store failure —
1339
- a service key that is not valid JSON, a file the process may not read — is
1340
- thrown unchanged. A failure in the reads that come before the token (the
1341
- session's connection config, the service key) stops the call before the
1342
- provider is asked; one in the reads that save it (the session's
1343
- authorization config, the session) arrives after the provider answered and
1344
- the new token was written. (The session stores of
1345
- auth-stores answer an unreadable session file with `null` themselves, so it
1346
- reads as absent before the broker sees it.)
1347
- - **Store writes**: a write that fails reaches the token API's caller — the
1348
- store's error, as raised, for the token it received — and is retried by the
1349
- broker all the same. A `getProvider` provider's write never fails the
1350
- authentication; it is retried, and `flush()` reports it.
1351
- - **A provider result without a token** is an error.
1352
-
1353
1073
  ```typescript
1354
- import { DestinationConfigError } from '@mcp-abap-adt/auth-broker';
1355
- import { ValidationError } from '@mcp-abap-adt/auth-providers';
1356
-
1357
- try {
1358
- const provider = await broker.getProvider('TRIAL');
1359
- } catch (error) {
1360
- if (error instanceof DestinationConfigError) {
1361
- logger.error(`${error.destination} lacks: ${error.missingFields.join(', ')}`);
1362
- }
1363
- throw error;
1074
+ import type {
1075
+ BrokerCallOptions,
1076
+ ClientAuthenticationStrategy,
1077
+ IAuthorizationConfig,
1078
+ IConnectionConfig,
1079
+ ILogger,
1080
+ IRefreshableTokenProvider,
1081
+ IRenewalStrategy,
1082
+ IServiceKeyStore,
1083
+ ISessionStore,
1084
+ ITokenRefresher,
1085
+ StrategyGrant,
1086
+ TokenGrant,
1087
+ TokenProviderClient,
1088
+ } from '@mcp-abap-adt/auth-broker';
1089
+ import type { IDeviceCodePresenter, OidcCallbackResult } from '@mcp-abap-adt/auth-providers';
1090
+ import type {
1091
+ IAssertionReplayStore,
1092
+ IAuthorizationStrategy,
1093
+ IAuthProvider,
1094
+ } from '@mcp-abap-adt/interfaces-auth';
1095
+
1096
+ export interface AuthBrokerConfig {
1097
+ sessionStore: ISessionStore; // required
1098
+ serviceKeyStore?: IServiceKeyStore | undefined; // getProvider needs it
1099
+ provider?: IRefreshableTokenProvider | TokenProviderFactory | undefined; // the token API's own
1100
+ authorization?: ((destination: string, grant: StrategyGrant) => IAuthorizationStrategy<string>) | undefined;
1101
+ oidcAuthorization?: ((destination: string) => IAuthorizationStrategy<OidcCallbackResult>) | undefined;
1102
+ deviceCodePresenter?: ((destination: string) => IDeviceCodePresenter) | undefined;
1103
+ samlCookies?: ((destination: string) => (samlResponse: string) => Promise<string>) | undefined;
1104
+ assertionReplayStore?: ((destination: string) => IAssertionReplayStore) | undefined;
1105
+ clientAuthentication?: ClientAuthenticationStrategy | undefined;
1106
+ renewal?: ((destination: string, grant: TokenGrant) => IRenewalStrategy) | undefined; // required by token rows
1107
+ onWriteFailure?: 'fail' | 'continue' | undefined; // required where a secret is written
1108
+ authDebug?: boolean | undefined; // on only for true
1364
1109
  }
1365
1110
 
1366
- try {
1367
- const token = await broker.getToken('TRIAL');
1368
- } catch (error) {
1369
- if (error instanceof ValidationError) {
1370
- logger.error(`Missing: ${error.missingFields?.join(', ')}`);
1371
- }
1372
- throw error;
1111
+ export declare class AuthBroker {
1112
+ constructor(config: AuthBrokerConfig, logger?: ILogger);
1113
+ getProvider(destination: string, options?: BrokerCallOptions): Promise<IAuthProvider>;
1114
+ getToken(destination: string, options?: BrokerCallOptions): Promise<string>;
1115
+ refreshToken(destination: string, options?: BrokerCallOptions): Promise<string>;
1116
+ flush(options?: BrokerCallOptions): Promise<void>;
1117
+ createTokenRefresher(destination: string, options?: BrokerCallOptions): ITokenRefresher;
1118
+ getAuthorizationConfig(destination: string): Promise<IAuthorizationConfig | null>;
1119
+ getConnectionConfig(destination: string): Promise<IConnectionConfig | null>;
1373
1120
  }
1374
- ```
1375
-
1376
- #### Secrets in the Session Store
1377
-
1378
- Everything the broker writes — a `getProvider` provider's token and the
1379
- token API's alike — is the secret alone: `authorizationToken` (or
1380
- `sessionCookies`), `expiresAt`, `refreshToken`, with `issuedFor` and
1381
- `issuedBy`, in one `saveSession`, and nothing else (see *Persistence and
1382
- `flush()`*). Never the client secret, never `serviceUrl` or `authType`:
1383
- `setConnectionConfig` and `setAuthorizationConfig` are not called. A store
1384
- that merges a write into what it holds keeps a client or URL it held beside
1385
- the secret; auth-stores 3 holds the secret alone.
1386
-
1387
- The stored refresh token comes back through `loadSession()`, which the broker
1388
- reads to seed the next process's provider.
1389
1121
 
1390
- ### Token Providers
1391
-
1392
- Provider implementations live in `@mcp-abap-adt/auth-providers` 5. `getProvider`
1393
- builds the one the destination states (see *A Provider for a Connector*); for
1394
- the token API the 3.x way you pass one yourself — an `IRefreshableTokenProvider`
1395
- or a factory:
1396
-
1397
- - **`ClientCredentialsProvider`** — `client_credentials`: no user, no browser,
1398
- no refresh token.
1399
- - **`AuthorizationCodeProvider`** — `authorization_code`: refreshes by the
1400
- stored refresh token, and logs in through the `authorization` strategy you
1401
- give it — there is no default. `browserCallbackStrategy({ browser?, port?,
1402
- timeoutMs? })` builds the usual one (callback port `61001` unless `port` is
1403
- given; held only for the duration of a login).
1404
-
1405
- ```typescript
1406
- import { AuthBroker } from '@mcp-abap-adt/auth-broker';
1407
- import {
1408
- XsuaaServiceKeyStore,
1409
- XsuaaSessionStore,
1410
- } from '@mcp-abap-adt/auth-stores';
1411
- import { ClientCredentialsProvider } from '@mcp-abap-adt/auth-providers';
1412
-
1413
- // XSUAA, client_credentials: credentials from the service key
1414
- const xsuaaBroker = new AuthBroker({
1415
- sessionStore: new XsuaaSessionStore('/path/to/sessions'),
1416
- serviceKeyStore: new XsuaaServiceKeyStore('/path/to/keys'),
1417
- provider: (destination, authConfig) => {
1418
- if (!authConfig) throw new Error(`No UAA credentials for ${destination}`);
1419
- return new ClientCredentialsProvider({
1420
- uaaUrl: authConfig.uaaUrl,
1421
- clientId: authConfig.uaaClientId,
1422
- clientSecret: authConfig.uaaClientSecret,
1423
- });
1424
- },
1425
- });
1122
+ export type TokenProviderFactory = (
1123
+ destination: string,
1124
+ authConfig: IAuthorizationConfig | null,
1125
+ connConfig: IConnectionConfig,
1126
+ client?: TokenProviderClient, // only beside clientAuthentication, for a grant that authenticates a client
1127
+ ) => IRefreshableTokenProvider;
1426
1128
  ```
1427
1129
 
1130
+ - **`getProvider(destination, { signal? })`** — the `IAuthProvider` the destination states (see
1131
+ *A Provider for a Connector*): the cached one while what its build read is unchanged, else a
1132
+ new build. A token or SNC provider gets the signal attached. Throws `DestinationConfigError`
1133
+ for a destination that lacks what its type needs; under `'fail'`, the `persisting-tokens`
1134
+ failure while the destination's last write is pending; a store's read failure as the store
1135
+ raised it.
1136
+ - **`getToken(destination, { signal? })`** / **`refreshToken(destination, { signal? })`** —
1137
+ `getTokens()` / `refreshTokens()` of the destination's provider (row path) or of yours
1138
+ (consumer path), the signal passed to it; a renewal already in flight is joined. See *Getting
1139
+ Tokens*.
1140
+ - **`flush({ signal? })`** — one more attempt for every pending write; see *Session Writes*.
1141
+ - **`createTokenRefresher(destination, { signal? })`** — `getToken` / `refreshToken` bound to
1142
+ one destination and signal.
1143
+ - **`getConnectionConfig(destination)`** — the key store's means with the session's
1144
+ `authorizationToken`, `sessionCookies`, `expiresAt`, `issuedFor` and `issuedBy` laid over
1145
+ them; `null` when neither store holds anything. **`getAuthorizationConfig(destination)`** —
1146
+ the key store's client (`uaaUrl`, `uaaClientId`, `uaaClientSecret`) with the session's refresh
1147
+ token; `null` when the key store has no client (an x509 key's included). Store reads with no
1148
+ signal.
1149
+ - **`TokenProviderClient`** — the factory's fourth argument: `clientAuthentication` (the
1150
+ strategy's answer), `uaaUrl` and `clientId` (the client identity); never a certificate, a key,
1151
+ a secret or a refresh token (`refreshToken` stays in the type and is never set).
1152
+ - **`StrategyGrant`** — `'authorization_code' | 'passcode' | 'saml2_pure' | 'saml2_bearer'`;
1153
+ **`TokenGrant`** — `'authorization_code' | 'client_credentials' | 'passcode' | 'oidc_authorization_code' | 'device_code' | 'password' | 'token_exchange' | 'saml2_pure' | 'saml2_bearer'` (every grant a
1154
+ destination may state but `'none'`); **`BrokerCallOptions`** —
1155
+ `{ signal? }`.
1156
+ - **`bindingOf(means, client?)`**, **`fromServiceKeyCertificate()`**,
1157
+ **`fromServiceKeySecret({ encoding })`**, **`DestinationConfigError`**,
1158
+ **`isDestinationConfigError`**, **`SessionWriteFailure`** — above.
1159
+ - Re-exported types: `IClientAuthentication`, `IRenewalStrategy`, `ITokenRefresher` (from
1160
+ `interfaces-auth`), `IClientCertificate` (`interfaces-auth-broker`), `AuthType`
1161
+ (`interfaces-auth-sap`), `ILogger` (`interfaces-utils`), the store contracts
1162
+ (`IAuthorizationConfig`, `IConnectionConfig`, `IServiceKeyStore`, `ISessionStore`, `IConfig`)
1163
+ and the token provider contracts (`IRefreshableTokenProvider`, `ITokenProvider`,
1164
+ `ITokenResult`, `TokenProviderOptions`). `IAuthProvider` is not re-exported: take it from
1165
+ `@mcp-abap-adt/interfaces-auth`.
1166
+
1167
+ ## Migrating to 5.0.0
1168
+
1169
+ What a 4.x consumer must now do, row by row. The full list of changes is the
1170
+ [CHANGELOG](CHANGELOG.md)'s 5.0.0 entry.
1171
+
1172
+ | 4.x | 5.0.0 — what to do |
1173
+ |---|---|
1174
+ | `@mcp-abap-adt/auth-providers` 5, `interfaces-auth` 3, `auth-stores` 3 | auth-providers `^6.0.0`, interfaces-auth `^7.5.0`, auth-errors `^2.1.1`, auth-stores `^4.0.0`; one installed copy of each contract (`npm ls`) |
1175
+ | token rows renewed by the provider's built-in rule | pass `renewal: () => refreshThenLogin()` — 4.x's steps; without `renewal` a token row is refused (`missingFields: ['renewal']`) |
1176
+ | a `getProvider` provider's failed write never failed the authentication; the token API threw the store's error | pass `onWriteFailure`: `'continue'` keeps 4.x's connector behaviour (best effort); `'fail'` keeps 4.x's token-API behaviour and extends it to the connector and to every later call of the destination while the write is pending. Without it a destination that writes a secret is refused |
1177
+ | errors of the token API: auth-providers 5.x classes (`ValidationError`, `BrowserAuthError`, `RefreshError`, …) | `AuthProviderFailure`: read it with `readFailure(error, operation)` and `matchKind` (auth-errors); auth-providers' *Migrating to 6.0.0* maps each class to its kind |
1178
+ | the token API's failed write: the store's own error | `AuthProviderFailure`, `unknown`, `persisting-tokens` (with an allowlisted `code`), only with `'fail'` |
1179
+ | "Token provider did not return authorization token …" (`Error`) | `request-failed`, `no-access-token`, operation `token-source` |
1180
+ | `error instanceof DestinationConfigError` | `isDestinationConfigError(error)`; `error.error` is the provider's error when one caused the refusal, and the message adds `: <its reason>` |
1181
+ | a provider's `ValidationError` thrown raw by `getProvider` (rows other than SNC) | a `DestinationConfigError` naming the store fields, the provider's error in `error` |
1182
+ | the certificate words of `clientAuthentication` (`incomplete` / `expired` / `could not be used`) | `error.reason` of the carried `client-certificate` failure; the message reads `the clientAuthentication strategy failed: <reason>` |
1183
+ | the token API with a consumer `provider`: a call waited for its session write however long the store took | the wait races the call's `signal`: an abort releases the caller (`aborted`) while the write runs on |
1184
+ | a failed session write was retried on a timer (1 s, doubling to 60 s) | no timer: it stays pending and is retried by the destination's next write or `flush()` — **call `flush()` on shutdown**. Under `'fail'` the destination's calls are refused while it is pending |
1185
+ | a store whose `saveSession` never settled kept being retried | **the store's contract:** `saveSession` must settle (resolve or reject). One that never settles holds that destination's write queue; each waiting caller is released by its own signal |
1186
+ | `flush()`'s `AggregateError.errors`: `Error("<dest>": <class>)` | `SessionWriteFailure` (`destination`, `error`) |
1187
+ | `getProvider(d)`, `getToken(d)`, `refreshToken(d)` | unchanged calls; each also takes `{ signal }` — tie a session's close to `getProvider`'s signal and each request's cancellation to the token API's |
1188
+ | providers' 30 s / 300 s login timeouts | none: a login waits until it ends or a signal aborts it; bound it with your own signal or the strategy's `signal` option |
1189
+ | collaborator strategies of auth-providers 5 (`browserCallbackStrategy({ browser: 'system', timeoutMs })`, `openUrl`) | auth-providers 6's: `browser` an `IBrowser` (`linuxDefaultBrowser()`, `macDefaultBrowser()`, `windowsDefaultBrowser()`, …), `signal` instead of `timeoutMs`, `redirectUri` required for the manual ones; a strategy of yours must honour `AuthorizationRequest.signal` |
1190
+ | a stored session bound by `issuedFor` / `issuedBy` (issuer and client) | the binding also names the row: `issuedBy` is a versioned record. **Every session written before 5.0.0 reads as unbound once**: for each token destination the stored token and refresh token are not used (one `warn` line) and the provider's first renewal is a **login, not a refresh** — interactive for `authorization_code`, `passcode`, `oidc_authorization_code`, `device_code` and the SAML grants; a token request for `client_credentials`, `password`, `token_exchange`. **A headless consumer must run that login once per destination after upgrading** (e.g. `mcp-auth --env <file>` or `--service-key` with CLI 3.0.0). A handed-over credential (`none`) is refused naming `issuedBy` until written again with 5.0.0's `bindingOf`. A switch of grant with unchanged resource and client no longer reuses the other grant's credential |
1191
+ | SAML trust (`samlIdpCertificates`, `samlIdpEntityId`, `samlSpEntityId`, `samlClockSkewMs`, `samlIdpInitiated`), OIDC scopes, the `password` grant's user, the certificate client's certificate, changed | the destination **logs in once** — within a running broker and after a restart; 4.x kept the cached provider and its session, accepting assertions under the trust it was built with |
1192
+ | any other value a build reads changed (a password, a client secret, a subject token, an option of the means) | within a running broker: a new provider, **one login**; after a restart, only a change the record holds (above) forces a login — a secret is not persisted in any form |
1193
+ | a `token_exchange` destination | obtains a fresh token after every restart (one token request, no interaction): its subject is a secret and cannot bind a stored session |
1194
+ | a server address the destination states changed (`uaaUrl`, `oidcIssuerUrl`, the three OIDC endpoints, `oidcAudience`, `samlIdpSsoUrl`, `samlAcsUrl`, `samlTokenUrl`, a certificate client's `certUrl`) or its client id | the stored token and refresh token are not reused: the destination **logs in once** after the change (4.x refreshed with the old refresh token at the new address, or kept its token). **Even a cosmetic change counts** — a trailing `/`, a case change, a port written out: the strings are compared exactly |
1195
+ | an OIDC destination with explicit endpoints only (no `oidcIssuerUrl`, no `uaaUrl`) — 4.x never seeded its session | **now reused after a restart** when its means are unchanged, byte for byte. Only a destination whose means lack the client the row authenticates, or an address its credential goes to, is never seeded and logs in after every restart |
1196
+ | means changed under a running broker were picked up by a new broker | picked up at the next call: a changed identity rebuilds the destination's provider, unseeded; an instance `provider` is refused for that destination until a new broker |
1197
+ | the token API's factory seeded with whatever the session held (`authConfig.refreshToken`, `connConfig`'s token and expiry, the fourth argument's `refreshToken`) | **handed no stored secret at all**: the means and the client only. A factory that relied on the stored session to resume after a restart must compose that itself — give its provider its own persistence (e.g. `refreshStatePersistence` over a store of yours) and its own seed — or use `getProvider`'s path, whose providers the broker seeds from a matching record. The broker still writes what the provider returns, with the `provider/…` record |
1198
+ | a consumer provider's result without a refresh token kept the stored one | it clears it (`refreshToken: ''`): auth-providers 6 providers return the refresh token they hold |
1199
+ | `onTokens` of the built providers | `persistence` (internal: nothing to do) |
1200
+ | `DEBUG_*` environment variables | the broker reads none; `authDebug: true` for the providers' debug line |
1201
+
1202
+ **The first run after upgrading costs one login (or one token request) per token destination**:
1203
+ plan it for headless consumers before switching them over. Migrations from earlier majors are in
1204
+ the [CHANGELOG](CHANGELOG.md) (*Migrating from 3.x* under 4.0.0; 4.1.0's additions under 4.1.0).
1205
+
1428
1206
  ## Testing
1429
1207
 
1430
- The library's tests are in `packages/auth-broker/src/__tests__/` and use Jest.
1431
- Run them from the repository root, where the dev dependencies are installed:
1208
+ The library's tests are in `packages/auth-broker/src/__tests__/` and use Jest. Run them from the
1209
+ repository root, where the dev dependencies are installed — never `npx jest` directly (the npm
1210
+ script sets `--experimental-vm-modules`):
1432
1211
 
1433
1212
  ```bash
1434
- # Every workspace's tests (library and CLI)
1435
- npm test
1436
-
1437
- # The library's only
1438
1213
  npm test -w @mcp-abap-adt/auth-broker
1439
-
1440
- # One file, or one case
1441
- npm test -w @mcp-abap-adt/auth-broker -- AuthBroker.test.ts
1442
1214
  npm test -w @mcp-abap-adt/auth-broker -- AuthBroker.test.ts -t "name of the case"
1443
1215
  ```
1444
1216
 
1445
- Tests run sequentially (`maxWorkers: 1` and `maxConcurrency: 1` in
1446
- `packages/auth-broker/jest.config.js`). `AuthBroker.test.ts` needs nothing;
1447
- `AuthBroker.integration.test.ts` reads real service keys and sessions, and
1448
- every case in it returns early unless the configuration below names a real
1449
- destination.
1450
-
1451
- ### Test Setup
1452
-
1453
- 1. Copy `packages/auth-broker/tests/test-config.yaml.template` to
1454
- `packages/auth-broker/tests/test-config.yaml` (until the workspace layout it
1455
- lived in `tests/` at the repository root; move an existing copy)
1456
- 2. Fill in configuration values (paths, destinations, MCP URL for XSUAA)
1457
- 3. Place service key files in configured `service_keys_dir`:
1458
- - `{destination}.json` for ABAP tests (e.g., `trial.json`)
1459
- - `{btp_destination}.json` for XSUAA tests (e.g., `btp.json`)
1460
-
1461
- Without `test-config.yaml` the template is read, its placeholders disable every
1462
- integration case, and the run reaches no system.
1217
+ Tests run sequentially (`maxWorkers: 1`, `maxConcurrency: 1`). The broker suites need nothing:
1218
+ fake stores of the contract or auth-stores 4 in temporary directories, real providers, a token
1219
+ endpoint the test starts on `127.0.0.1`. `AuthBroker.integration.test.ts` reads real service
1220
+ keys and sessions only when `packages/auth-broker/tests/test-config.yaml` exists (from the
1221
+ template beside it); without it every case returns at once. The stand (`npm run test:stand`,
1222
+ UAA and Keycloak in Docker), the live checks (`npm run test:live`, `npm run test:live:x509`) and
1223
+ what each needs: [`docs/development/TESTING.md`](../../docs/development/TESTING.md).
1463
1224
 
1464
1225
  ### The x509 Live Check
1465
1226
 
1466
- `npm run test:live:x509` (from the repository root or this package) runs
1467
- `client_credentials` with an x509 XSUAA service key against a real BTP
1468
- subaccount: it builds, creates an `xsuaa` / `application` instance
1469
- (`credential-types: ["binding-secret", "x509"]`) and a key made with
1470
- `{"credential-type": "x509"}`, runs `src/__tests__/live/x509.live.test.ts`
1471
- — the broker with `fromServiceKeyCertificate()`, `mcp-auth --client-auth
1472
- certificate`, `generate-env-from-service-key --client-auth certificate`, a
1473
- fresh broker over each written destination, and a failing run that leaves the
1474
- previous destination untouched and prints no PEM — and removes everything,
1475
- also when a test fails. Not in `npm test`, not in CI.
1476
-
1477
- It refuses to run unless `XSUAA_CF_API`, `XSUAA_CF_ORG` and `XSUAA_CF_SPACE`
1478
- equal exactly what `cf target` shows (`cf login -a <api> --sso -o <org> -s
1479
- <space>` first). It touches only what it created: each resource is recorded
1480
- with its GUID in a ledger, `tests/live/x509/.local/owned` (gitignored, with
1481
- the key and its PEM files, owner-only), bound to that API, org and space, and
1482
- re-checked before every reuse or delete; a name held by anything else is
1483
- refused. A failed teardown exits non-zero and keeps `.local/` — run
1484
- `tests/live/x509/teardown.sh` with the same three variables. `X509_KEEP=1`
1485
- keeps the environment for another run; `-- -t "(b)"` runs one case. Last run:
1486
- BTP trial, 2026-10-05, all five cases passed.
1227
+ `npm run test:live:x509` (from the repository root or this package) runs `client_credentials`
1228
+ with an x509 XSUAA service key against a real BTP subaccount: it builds, creates an `xsuaa` /
1229
+ `application` instance (`credential-types: ["binding-secret", "x509"]`) and a key made with
1230
+ `{"credential-type": "x509"}`, runs `src/__tests__/live/x509.live.test.ts` — the broker with
1231
+ `fromServiceKeyCertificate()`, `mcp-auth --client-auth certificate`, `generate-env
1232
+ --client-auth certificate`, a fresh broker over each written destination, and a failing run that
1233
+ leaves the previous destination untouched and prints no PEM — and removes everything, also when
1234
+ a test fails. Not in `npm test`, not in CI. It refuses to run unless `XSUAA_CF_API`,
1235
+ `XSUAA_CF_ORG` and `XSUAA_CF_SPACE` equal exactly what `cf target` shows, and touches only what
1236
+ it recorded in its ledger (`tests/live/x509/.local/owned`). Last run: BTP trial, 2026-10-05, on
1237
+ 4.1.0, all five cases passed.
1487
1238
 
1488
1239
  ## Documentation
1489
1240
 
1490
- Complete documentation is available in the repository's [`docs/`](../../docs/) directory:
1491
-
1492
- - **[Architecture](../../docs/architecture/ARCHITECTURE.md)** - System architecture and design decisions
1493
- - **[Testing](../../docs/development/TESTING.md)** - Where the suites live, what they need, the release checks
1494
- - **[Installation](../../docs/installing/INSTALLATION.md)** - Installation and setup guide
1495
- - **[Usage](../../docs/using/USAGE.md)** - API reference and usage examples
1496
-
1497
- See [docs/README.md](../../docs/README.md) for the complete documentation index.
1241
+ The repository's [`docs/`](../../docs/): [Architecture](../../docs/architecture/ARCHITECTURE.md),
1242
+ [Exports](../../docs/architecture/EXPORTS.md), [Testing](../../docs/development/TESTING.md),
1243
+ [Installation](../../docs/installing/INSTALLATION.md), [Usage](../../docs/using/USAGE.md); the
1244
+ index is [docs/README.md](../../docs/README.md).
1498
1245
 
1499
1246
  ## Contributors
1500
1247
 
@@ -1524,4 +1271,3 @@ set of additional permissions over the GPL and cannot be read alone.
1524
1271
  program — importing it, as every consumer of an npm package does — does not put
1525
1272
  your program under the LGPL. What the licence asks is that changes *to this
1526
1273
  library* stay free, and that your users can replace it with their own build.
1527
-