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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,293 +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).
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).
13
14
 
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.
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.
17
20
 
18
- The `mcp-auth` and `mcp-sso` commands that write destination files are in
19
- [`@mcp-abap-adt/auth-broker-cli`](../auth-broker-cli/README.md) (2.0.0, on this
20
- version), in the same repository. Up to 3.0.4 they shipped in this package; from 3.1.0 this package
21
- has no `bin`. Install the commands with
22
- `npm i -g @mcp-abap-adt/auth-broker-cli` — after
23
- `npm uninstall -g @mcp-abap-adt/auth-broker` if you had installed this one
24
- globally for them.
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`.
25
24
 
26
25
  ## Features
27
26
 
28
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
29
- - 💾 **What a provider obtains is stored**: every token or set of SAML session cookies a `getProvider` provider obtains or renews — at `prepare()`, on expiry, or after a 401 in `rejected()` — is written to the session store, the secret alone with what it is bound to; a failed write is retried by the broker, and `flush()` tells you whether everything landed
30
- - 🔒 **A secret goes only where it was obtained**: a stored token is used only for the resource it was obtained for, from the issuer and client that issued it — otherwise it is discarded and the provider logs in afresh
31
- - 🎯 **Per destination**: one provider per destination name, shared by `getProvider` and the token API — one token, one refresh token, one renewal in flight
32
- - 🔄 **Provider-driven token lifecycle**: The provider decides whether its cached token is still good, refreshes it, or logs in; the broker persists what it returns
33
- - 🪙 **A token API for whoever wants a token and nothing else**: `getToken`, `refreshToken` and `createTokenRefresher` on the destination's own provider, or on one you give the broker, as in 3.x
34
- - ⚡ **Forced refresh**: `refreshToken()` obtains a new token even when the cached one looks valid — for a caller holding a 401
35
- - 🧾 **JWT or SAML cookies**: what the provider returns is saved as a token or as session cookies
36
- - 🔑 **No secrets copied**: The client secret stays in the service key; the session store gets tokens only
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
37
35
 
38
36
  ## Installation
39
37
 
40
38
  ```bash
41
- 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
42
40
  ```
43
41
 
44
- Requires Node.js 22, 24 or 26 (`engines: "^22 || ^24 || ^26"`): 22 and 24 are
45
- the versions SAP BTP's Cloud Foundry Node.js buildpack offers, and 26 is
46
- 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.
47
52
 
48
- ## 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.
49
55
 
50
- ### Basic Usage
56
+ ## Usage
51
57
 
52
- This is the token API with a provider of your own — the 3.x way, which keeps
53
- working. The broker takes a session store, an optional service key store, and
54
- a token provider implementing `IRefreshableTokenProvider` (from
55
- `@mcp-abap-adt/interfaces-auth`) — or a factory that builds one per
56
- destination. Without a `provider` the token API serves what the destination
57
- states, on the provider `getProvider` builds (see *Getting Tokens*); for a
58
- `@mcp-abap-adt/connection` 10 connector, use `getProvider` itself.
58
+ ### A Provider for a Connector: `getProvider`
59
59
 
60
60
  ```typescript
61
61
  import { AuthBroker } from '@mcp-abap-adt/auth-broker';
62
+ import {
63
+ browserCallbackStrategy,
64
+ linuxDefaultBrowser,
65
+ refreshThenLogin,
66
+ } from '@mcp-abap-adt/auth-providers';
62
67
  import {
63
68
  AbapServiceKeyStore,
64
69
  AbapSessionStore,
70
+ EnvDestinationStore,
65
71
  } from '@mcp-abap-adt/auth-stores';
66
- import {
67
- AuthorizationCodeProvider,
68
- browserCallbackStrategy,
69
- } from '@mcp-abap-adt/auth-providers';
70
72
 
71
73
  const broker = new AuthBroker(
72
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.
73
82
  sessionStore: new AbapSessionStore('/path/to/sessions'),
74
- serviceKeyStore: new AbapServiceKeyStore('/path/to/keys'), // optional
75
- // Called once per destination, seeded with what the stores hold.
76
- provider: (destination, authConfig, connConfig) => {
77
- if (!authConfig) throw new Error(`No UAA credentials for ${destination}`);
78
- return new AuthorizationCodeProvider({
79
- uaaUrl: authConfig.uaaUrl,
80
- clientId: authConfig.uaaClientId,
81
- clientSecret: authConfig.uaaClientSecret,
82
- refreshToken: authConfig.refreshToken, // stored by an earlier login
83
- accessToken: connConfig.authorizationToken, // reused while valid
84
- authorization: browserCallbackStrategy({ browser: 'system' }),
85
- });
86
- },
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() }),
87
89
  },
88
90
  logger, // optional ILogger
89
91
  );
90
92
 
91
- const token = await broker.getToken('TRIAL');
92
- ```
93
-
94
- The factory receives:
95
-
96
- - `authConfig` — the UAA credentials: the session's own when it holds them,
97
- else the service key's; carrying the refresh token the session stored.
98
- `null` when no store has credentials (a SAML flow needs none).
99
- - `connConfig` — the session's connection config, with `serviceUrl` resolved
100
- (from the session, else the service key) and the token stored last.
101
-
102
- These are 3.x's reads, in 3.x's order, kept for this path: a session store
103
- that still answers a client or a URL is read first. auth-stores 3's session
104
- stores answer neither, so the key store's are what is found. What the broker
105
- writes back is the session secret alone (see *Getting Tokens*).
106
-
107
- A provider instance can be passed instead of a factory; it is then used as
108
- given, for every destination, and the broker seeds it with nothing:
109
-
110
- ```typescript
111
- const broker = new AuthBroker({
112
- sessionStore: new AbapSessionStore('/path/to/sessions'),
113
- provider: new AuthorizationCodeProvider({
114
- uaaUrl, clientId, clientSecret,
115
- authorization: browserCallbackStrategy({ browser: 'system' }),
116
- }),
117
- });
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) …
118
96
  ```
119
97
 
120
- > `AuthorizationCodeProvider` and the other `@mcp-abap-adt/auth-providers` 5
121
- > providers implement `IRefreshableTokenProvider` (from `@mcp-abap-adt/interfaces-auth`
122
- > 3). Since auth-providers 5 the interactive ones take their `authorization`
123
- > strategy explicitly — none is built for you.
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:
124
101
 
125
- ### A Provider for a Connector: `getProvider`
126
-
127
- `getProvider(destination)` returns the `IAuthProvider` (from
128
- `@mcp-abap-adt/interfaces-auth` 3) that a `@mcp-abap-adt/connection` 10
129
- connector takes as it is. The destination states which provider it gets; the
130
- broker reads two stores, each for one role:
131
-
132
- - **the means** — `authType`, `grantType`, basic's user and password, the SNC,
133
- OIDC and SAML fields, `serviceUrl`, the client — from the **service key store**
134
- (`getConnectionConfig`), and only from there;
135
- - **the secret** — the token or session cookies, `expiresAt`, the refresh
136
- token — from the **session store** (`loadSession`), and only from there.
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.
137
108
 
138
- Means a session store happens to answer are not read, and neither is a token
139
- or cookies a key store happens to answer. Nothing is inferred: the
140
- destination's `authType` (and `grantType`, for `jwt` and `saml`) decides the
141
- provider, never which other fields are present.
142
-
143
- ```typescript
144
- import { AuthBroker } from '@mcp-abap-adt/auth-broker';
109
+ Nothing is inferred: the destination's `authType` (and `grantType`, for `jwt` and `saml`)
110
+ decides the provider, never which other fields are present.
145
111
 
146
- const broker = new AuthBroker({
147
- serviceKeyStore: myKeyStore, // the means — required by getProvider
148
- sessionStore: mySessionStore, // the secret
149
- // no `provider`: that option is the token API's source, not getProvider's
150
- });
151
-
152
- const provider = await broker.getProvider('DEV'); // an IAuthProvider
153
- // const connection = createAbapConnection({ url, provider }, logger) …
154
- ```
155
-
156
- | `authType` / `grantType` | Provider (auth-providers 5.2) | Read from the key store | Read from the session store |
112
+ | `authType` / `grantType` | Provider (auth-providers 6) | Read from the key store | Read from the session store |
157
113
  |---|---|---|---|
158
114
  | `basic` (no grant read) | `new BasicAuthProvider(username, password)` | `username`, `password` | nothing |
159
115
  | `snc` (no grant read) | `SncLogonProvider.forSecureLoginClient({ partnerName, qop, sncLib, myName, logger })` | `sncPartnerName` (required); `sncQop`, `sncLib`, `sncMyName` when set | nothing |
160
- | `jwt` / `authorization_code` | `AuthorizationCodeProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret`; `serviceUrl`, `sapClient` for the binding | the seed: `authorizationToken`, `refreshToken`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
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* |
161
117
  | `jwt` / `client_credentials` | `ClientCredentialsProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret`; `serviceUrl`, `sapClient` for the binding | nothing (the row takes the client alone) |
162
- | `jwt` / `passcode` | `UaaPasscodeProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret` (`''` = a public client); `serviceUrl`, `sapClient` for the binding | the seed: `authorizationToken`, `refreshToken`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
163
- | `jwt` / `oidc_authorization_code` | `OidcBrowserProvider` (PKCE) | `uaaClientId`, `uaaClientSecret` (`''` = a public client); `oidcIssuerUrl`, or `oidcAuthorizationEndpoint` + `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` for the binding | the seed — used only when `issuedFor` and `issuedBy` match |
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 |
164
120
  | `jwt` / `device_code` | `OidcDeviceFlowProvider` | the client as above; `oidcIssuerUrl`, or `oidcDeviceAuthorizationEndpoint` + `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` | the seed, as above |
165
121
  | `jwt` / `password` | `OidcPasswordProvider` | the client as above; `username`, `password`; `oidcIssuerUrl`, or `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` | the seed, as above |
166
- | `jwt` / `token_exchange` | `OidcTokenExchangeProvider` (RFC 8693) | the client as above; `oidcSubjectToken`, `oidcSubjectTokenType`; `oidcAudience`, `oidcActorToken`, `oidcActorTokenType` when set; `oidcScopes`, joined by one space into its `scope`; `oidcIssuerUrl`, or `oidcTokenEndpoint`; `serviceUrl`, `sapClient` | the seed, as above (it has no refresh grant: a renewal exchanges again) |
167
- | `saml` / `saml2_pure` | `Saml2PureProvider` | `samlIdpSsoUrl`, `samlSpEntityId`, `samlIdpEntityId` (the expected issuer), `samlIdpCertificates`; `samlAcsUrl`, `samlRelayState`, `samlIdpInitiated`, `samlClockSkewMs` when set; `serviceUrl`, `sapClient` for the binding | the seed: `sessionCookies`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
168
- | `saml` / `saml2_bearer` | `Saml2BearerProvider` (RFC 7522) | the same SAML fields; `samlTokenUrl` when set; the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret` (`''` = a public client); `serviceUrl`, `sapClient` | the seed: `authorizationToken`, `refreshToken`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
169
- | `jwt` / `none` | `TokenAuthProvider.fixed(authorizationToken)` | `authType`, `grantType`, `serviceUrl` (+ `sapClient`); an issuer, when stated: `oidcIssuerUrl`, or the client's `uaaUrl` + `uaaClientId` | `authorizationToken` (required), `issuedFor` (required to match); `issuedBy` when the means state an issuer |
170
- | `saml` / `none` | `new SamlAuthProvider(sessionCookies)` | `authType`, `grantType`, `serviceUrl` (+ `sapClient`); `samlAcsUrl` when stated | `sessionCookies` (required), `issuedFor` (required to match); `issuedBy` when `samlAcsUrl` is stated |
171
-
172
- **No provider reads `serviceUrl`.** The URL of the system is where the
173
- connector connects, not authorization data: no token provider reads it — a
174
- UAA grant needs the client and `uaaUrl` — so `getProvider` neither requires it
175
- nor passes it to a provider. Give the connector its URL from your key store
176
- (`getConnectionConfig`), as the key store answers it. The broker reads it,
177
- with `sapClient`, for one thing only: to bind a stored secret to the resource
178
- it was obtained for (see *A Secret Is Bound to Its Resource and Issuer*). A
179
- token destination without it still gets its provider, but no stored secret
180
- is reused for it.
181
-
182
- `none` is how a handed-over credential is stated: the key store says
183
- `grantType: 'none'`, and the token or cookies live in the session. The SNC
184
- row takes the contract's own defaults when a field is absent — the library is
185
- discovered (`SNC_LIB_64`, `SNC_LIB`, the Secure Login Client's install path)
186
- when `sncLib` is, the user's SNC name comes from the credential when
187
- `sncMyName` is, and `qop` is the provider's `'9'` when `sncQop` is.
188
-
189
- The allowed pairs are `jwt` with `authorization_code`, `client_credentials`,
190
- `passcode`, `oidc_authorization_code`, `device_code`, `password`,
191
- `token_exchange` or `none`, and `saml` with `saml2_pure`, `saml2_bearer` or
192
- `none`. A pair outside them is a `DestinationConfigError` naming `grantType`.
193
-
194
- **The UAA grants.** The client comes from the key store's
195
- `getAuthorizationConfig` — never from the session store. A session is the
196
- seed: the stored token is presented while it is valid (a JWT's own `exp`
197
- decides; the stored `expiresAt` serves a token that carries none), and the
198
- stored refresh token renews it. Without a session the provider is built
199
- unseeded and obtains its first token at `prepare()`. `uaaClientSecret: ''` is
200
- a public client: `passcode` takes it as no secret; `AuthorizationCodeProvider`
201
- and `ClientCredentialsProvider` require a secret, so for them `''` is missing.
202
-
203
- **The `authorization` option** is the interactive half of
204
- `authorization_code` and `passcode`: a function of the destination and the
205
- grant, returning the `IAuthorizationStrategy<string>` the provider logs in
206
- with. The broker calls it once, when it builds that destination's provider,
207
- and never disposes what it returns — whoever constructs, disposes. For
208
- `passcode` the strategy is handed `<uaaUrl>/passcode` as the URL to send the
209
- user to, and returns the code. There is no default: a destination whose grant
210
- needs it, without it, is a `DestinationConfigError` naming `authorization`.
211
- A headless process passes one that refuses (see *Headless Processes*).
212
-
213
- ```typescript
214
- import { browserCallbackStrategy, manualPasscodeStrategy } from '@mcp-abap-adt/auth-providers';
215
-
216
- const broker = new AuthBroker({
217
- serviceKeyStore: myKeyStore,
218
- sessionStore: mySessionStore,
219
- authorization: (destination, grant) =>
220
- grant === 'passcode'
221
- ? manualPasscodeStrategy({ timeoutMs: 300_000 })
222
- : browserCallbackStrategy({ timeoutMs: 120_000 }),
223
- });
224
- ```
225
-
226
- **The OIDC grants.** The client is the key store's `getAuthorizationConfig`
227
- again: `uaaClientId`, and `uaaClientSecret` — `''` is a public client, sent
228
- with no secret. (A key store answers a client when it holds one: from
229
- auth-stores 3.2.0, `EnvDestinationStore` answers one whenever `uaaClientId`
230
- is stated, a missing `uaaUrl` or secret as `''` — so an OIDC destination
231
- states its issuer in `oidcIssuerUrl` alone, and a public client no secret.)
232
- The endpoints come from `oidcIssuerUrl`, which the provider discovers them
233
- from, or — without it — from every explicit endpoint the row reads
234
- (`oidcAuthorizationEndpoint`, `oidcDeviceAuthorizationEndpoint`,
235
- `oidcTokenEndpoint`); without either the error names `oidcIssuerUrl` and the
236
- endpoints missing. `oidcScopes` is passed as given (`token_exchange` takes
237
- one `scope` string: the scopes joined by a space). The subject and actor
238
- tokens of `token_exchange` and the user and password of `password` are
239
- means: sent to the token endpoint, never written to the session.
240
-
241
- **The SAML grants.** Each provider validates the assertion before anything
242
- uses it, with a validator the broker composes from the destination's trust
243
- and your replay store: `createSignedResponseValidator` for `saml2_pure` (the
244
- Response must be signed — the cookies' system receives it whole) and
245
- `createSignedAssertionValidator` for `saml2_bearer` (the Assertion must be
246
- signed — the token endpoint receives it alone), from `samlIdpCertificates`
247
- (PEM or base64 DER; several during a rotation), `samlClockSkewMs` and
248
- `assertionReplayStore(destination)`; `samlIdpEntityId` is the issuer every
249
- assertion must name. A certificate the validator cannot read, or a
250
- `samlClockSkewMs` that is not a whole, non-negative number of milliseconds,
251
- is a `DestinationConfigError` naming the field. `samlIdpInitiated: true`
252
- declares an IdP-initiated login: no AuthnRequest, and an assertion carrying
253
- no `InResponseTo` — your strategy then hands over the SAMLResponse without
254
- asking for an authorization URL (a strategy that asks gets Oops from
255
- `prepare()`). `saml2_pure` is seeded with the stored cookies and their
256
- `expiresAt` — cookies carry no expiry of their own, so the provider keeps them
257
- until the assertion's earliest `NotOnOrAfter` (less the provider's one-minute
258
- margin: an identity provider whose assertions live a minute or less makes
259
- every request a login); SAML has no refresh token, so a renewal is a new
260
- login through your strategy. `saml2_bearer` posts the Assertion to
261
- `samlTokenUrl`, else `<uaaUrl>/oauth/token`, with the client, and renews by
262
- its refresh token.
263
-
264
- **The collaborator options** — each a function of the destination, called
265
- once when that destination's provider is built, never disposed by the
266
- broker, and required only by the rows that use it; a row whose option is
267
- missing is a `DestinationConfigError` naming it:
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:
268
183
 
269
184
  | Option | Rows | What it returns |
270
185
  |---|---|---|
271
- | `authorization(destination, grant)` | `authorization_code`, `passcode`, `saml2_pure`, `saml2_bearer` (the grant is passed) | the `IAuthorizationStrategy<string>` that conducts the login — for the SAML grants, the one that returns the SAMLResponse (`samlCallbackStrategy`, `manualSamlResponseStrategy`, …) |
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 |
272
187
  | `oidcAuthorization(destination)` | `oidc_authorization_code` | an `IAuthorizationStrategy<OidcCallbackResult>` (`oidcCallbackStrategy`, or `asOidcResult(…)` over a string strategy) |
273
188
  | `deviceCodePresenter(destination)` | `device_code` | an `IDeviceCodePresenter` that shows the user the verification URL and code (`consoleDeviceCodePresenter(logger)`, or your UI) |
274
189
  | `samlCookies(destination)` | `saml2_pure` | `(samlResponse) => Promise<string>`: posts the validated SAMLResponse to the system's ACS and returns the session cookies it sets |
275
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 |
276
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
+
277
195
  ```typescript
196
+ import { AuthBroker } from '@mcp-abap-adt/auth-broker';
278
197
  import {
198
+ browserCallbackStrategy,
279
199
  consoleDeviceCodePresenter,
280
200
  defaultReplayStore,
201
+ linuxDefaultBrowser,
202
+ manualPasscodeStrategy,
281
203
  oidcCallbackStrategy,
204
+ refreshThenLogin,
282
205
  samlCallbackStrategy,
283
206
  } from '@mcp-abap-adt/auth-providers';
284
207
 
285
- const broker = new AuthBroker(
208
+ const browser = linuxDefaultBrowser(); // macDefaultBrowser(), windowsDefaultBrowser(), …
209
+
210
+ const ssoBroker = new AuthBroker(
286
211
  {
287
212
  serviceKeyStore: myKeyStore,
288
213
  sessionStore: mySessionStore,
214
+ renewal: () => refreshThenLogin(),
215
+ onWriteFailure: 'fail',
289
216
  authorization: (destination, grant) =>
290
- grant === 'saml2_pure' || grant === 'saml2_bearer'
291
- ? samlCallbackStrategy({ timeoutMs: 120_000 })
292
- : browserCallbackStrategy({ timeoutMs: 120_000 }),
293
- oidcAuthorization: () => oidcCallbackStrategy({ timeoutMs: 120_000 }),
217
+ grant === 'passcode'
218
+ ? manualPasscodeStrategy()
219
+ : grant === 'saml2_pure' || grant === 'saml2_bearer'
220
+ ? samlCallbackStrategy({ browser })
221
+ : browserCallbackStrategy({ browser }),
222
+ oidcAuthorization: () => oidcCallbackStrategy({ browser }),
294
223
  deviceCodePresenter: () => consoleDeviceCodePresenter(logger),
295
224
  samlCookies: (destination) => (samlResponse) =>
296
225
  postToAcs(destination, samlResponse), // yours: the system's ACS answers Set-Cookie
@@ -300,207 +229,475 @@ const broker = new AuthBroker(
300
229
  );
301
230
  ```
302
231
 
303
- ### A Secret Is Bound to Its Resource and Issuer
304
-
305
- A token's audience, cookies' host and path, a system's client: presenting a
306
- secret to a resource it was not obtained for is a leak, and a secret from
307
- another authorization server or client must not stand in for this
308
- destination's. So the session store keeps, beside the secret, two strings
309
- (`IConnectionConfig`, `@mcp-abap-adt/interfaces-auth-broker` 1.1.0):
310
-
311
- - **`issuedFor`** — the resource: `serviceUrl` with the SAP client, e.g.
312
- `https://my-abap.example.com:443/sap/bc/adt?sap-client=100`;
313
- - **`issuedBy`** — who issued it, to which client: `uaaUrl` with
314
- `client_id=<uaaClientId>` for the UAA grants and `saml2_bearer`, e.g.
315
- `https://sub.authentication.us10.hana.ondemand.com:443?client_id=sb-abap-trial`;
316
- `oidcIssuerUrl` — else `uaaUrl` — with `client_id=<uaaClientId>` for the
317
- OIDC grants; for `saml2_pure`'s cookies, the ACS of the system that set
318
- them: `samlAcsUrl`, origin and path.
319
-
320
- The broker computes both from the destination's means and compares them,
321
- canonicalised on **both** sides, with what the session holds:
322
-
323
- - **The canonical form:** scheme and host lower-cased; the port explicit
324
- (`443` for `https`, `80` for `http`); the path without a trailing `/` (the
325
- root path is empty); one query parameter at most — `sap-client` (the means'
326
- `sapClient` wins over a `sap-client` already in `serviceUrl`) or
327
- `client_id` — parsed and re-encoded, so a value percent-encoded on one side
328
- and plain on the other compares equal; no user info, no other parameter,
329
- no fragment. A URL that does not parse binds nothing. A store keeps the
330
- strings as given and need not canonicalise them.
331
- - **The grants that obtain a secret** (UAA, OIDC, SAML)**:** a stored secret seeds the provider only when **both**
332
- stored values equal the computed ones. Otherwise — either different, or
333
- absent on either side (no `serviceUrl` in the means, an OIDC destination
334
- stating neither `oidcIssuerUrl` nor `uaaUrl`, a `saml2_pure` one without
335
- `samlAcsUrl`, a session written without them) — the secret is not used,
336
- **refresh token included**: the
337
- provider is built as with no session and logs in afresh by its grant, and
338
- the log says only `<destination>: secret bound to another resource,
339
- discarded` — never a URI, never a token. The new secret is written with
340
- both fields.
341
- - **The `none` rows** present a credential the broker cannot obtain again, so
342
- a mismatch is refused, not discarded: a `DestinationConfigError` naming
343
- `issuedFor` when the stored resource differs or is absent (or the means
344
- state no `serviceUrl`), and naming `issuedBy` when the means state an issuer
345
- (`oidcIssuerUrl` or the client for `jwt`, `samlAcsUrl` for `saml` — then
346
- canonicalised as origin and path) and the stored one differs or is absent.
347
- Means that state no issuer leave `issuedBy` uncompared.
348
- - **The binding does not check the token.** Its audience stays the
349
- resource's to enforce; the broker compares two strings and parses no token.
350
- - **`bindingOf(means, client?)` — for a consumer that hands over a
351
- credential.** A `none` destination presents a token or cookies the broker
352
- cannot obtain; whoever writes them to the session store must write the
353
- binding beside them. `bindingOf` answers exactly what the broker computes for
354
- those means — the one function `getProvider` checks against and `persist`
355
- writes (`issuedFor` from `serviceUrl` + `sapClient`, `issuedBy` from the
356
- issuer, client or ACS the grant uses) — so a consumer never canonicalises a
357
- URI itself:
358
-
359
- ```typescript
360
- const means = await keyStore.getConnectionConfig('DEV'); // saml / none
361
- await sessionStore.saveSession('DEV', {
362
- sessionCookies: cookies,
363
- ...bindingOf(means ?? {}), // { issuedFor: 'https://dev.example.com:443?sap-client=100' }
364
- });
365
- ```
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)`).
366
235
 
367
- A destination that states no `jwt` / `saml` type or no grant binds nothing
368
- (`{}`). `mcp-sso … --cookie` writes its cookies this way.
236
+ ### How a Token Provider Renews: `renewal`
369
237
 
370
- **What you meet:**
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();
371
244
 
372
- 1. **A custom `ISessionStore`** (a database, a message log, a secret store)
373
- must persist `issuedFor` and `issuedBy` beside the secret, and answer
374
- them from `loadSession`. One that drops them still type-checks, but the
375
- broker then never reuses its sessions: every process start is a fresh
376
- login — a browser each time for an interactive grant.
377
- 2. **A headless process whose strategy refuses logins** gets Oops ("login
378
- required") from `prepare()` / `rejected()` on a mismatch, instead of
379
- presenting a foreign secret; log in again with the CLI.
380
- 3. **Changing a destination's URL, SAP client, UAA or client** costs one
381
- fresh login — intended.
382
- 4. **Session files written before auth-stores 3.1.0** keep working: its
383
- session stores answer `issuedFor` from the file's `SAP_URL` (+
384
- `SAP_CLIENT`) and `issuedBy` from `SAP_UAA_URL` + `SAP_UAA_CLIENT_ID`,
385
- which the 3.x broker and CLI wrote with the token — so a stored token whose
386
- URL and client are the destination's is reused. XSUAA sessions written with
387
- an empty URL have no `issuedFor`, and log in once.
388
-
389
- **One provider per destination** for the broker's life, shared with the token
390
- API when the broker has no `provider` option (see *Getting Tokens*):
391
- concurrent first calls share one build, and a build that threw is tried again
392
- on the next call. A destination rewritten from outside is picked up by a new
393
- broker.
394
-
395
- ### Persistence and `flush()`
396
-
397
- Every token provider `getProvider` builds writes what it obtains back to the
398
- session store, through its `onTokens` hook, before it answers — whichever
399
- moment triggered the renewal: `prepare()`, `authorize()` on expiry, or
400
- `rejected()` after a 401. A renewal inside a connector is stored before the
401
- connector resends.
402
-
403
- - **The secret alone, with its binding, in one write**:
404
- `saveSession(destination, { authorizationToken, expiresAt, refreshToken,
405
- issuedFor, issuedBy })` — or, for `saml2_pure`'s cookies (a result of
406
- `tokenType: 'saml'`), `{ sessionCookies, expiresAt, issuedFor, issuedBy }`,
407
- with no refresh token: SAML has none. `saml2_bearer`'s result is a token
408
- and is written as one — `issuedFor` / `issuedBy` as computed when the
409
- provider was built, each left out when the means lack its source (so the
410
- store clears it). No means is ever written — not `serviceUrl`, not
411
- `authType`, not the client: the client secret lives in the key store, and
412
- the broker never writes the key store (its contract has no write method).
413
- `expiresAt` is the result's, else the `expires_in` it reported counted from
414
- when it arrived. A result without a refresh token keeps the one the session
415
- holds — when that one is bound where the new secret is; one bound to
416
- another resource or issuer is not carried into it.
417
- - **`basic` and `snc` are never written**: they obtain no session secret, and
418
- `none` obtains nothing either. A destination the key store states as
419
- `basic` or `snc` at write time is not written.
420
- - **The token API writes through the same path** (see *Getting Tokens*):
421
- with a `provider` of yours, every answer it gets is written as above — the
422
- stored refresh token carried forward whatever its binding, as your provider
423
- was seeded with it.
424
- - **A failed write does not fail the authentication.** The connector goes on
425
- with the token it holds; the broker keeps the result pending for that
426
- destination and retries on its own — one second, doubling, capped at one
427
- minute — on a timer that never keeps the process alive. A newer result
428
- replaces the pending one; writes for one destination never overlap. Each
429
- failure is logged by its class name, never its message. The token API's
430
- caller still gets the failure: `getToken()` / `refreshToken()` throw the
431
- store's error for the token they received, while the retry goes on.
432
- - **`flush()`** gives every pending write one more attempt, resolves when all
433
- have landed, and rejects (an `AggregateError` naming the destinations; each
434
- of its `errors` is `"<destination>": <error class>` — never the store's own
435
- message, which may quote what was being written) when the store still
436
- refuses — the broker keeps retrying.
437
- Call it on shutdown — on `SIGTERM`, before a stdio transport closes — to
438
- know whether every token is stored.
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:
439
341
 
440
342
  ```typescript
343
+ import { SessionWriteFailure } from '@mcp-abap-adt/auth-broker';
344
+
441
345
  process.on('SIGTERM', async () => {
442
346
  try {
443
347
  await broker.flush();
444
348
  } catch (error) {
445
- logger.error(`Tokens not stored: ${(error as Error).message}`);
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
+ }
446
354
  }
447
355
  process.exit(0);
448
356
  });
449
357
  ```
450
358
 
451
- **A destination that lacks what its type needs** is a `DestinationConfigError`
452
- (see *Error Handling*), thrown by `getProvider` before any provider exists —
453
- never a provider built only to refuse in `prepare()`.
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:
454
361
 
455
- ### Headless Processes (No Browser)
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.
456
379
 
457
- Whether a login may open a browser is the provider's authorization strategy,
458
- not a broker option. A process nobody is watching (an MCP server on stdio, a
459
- CI job) gives the provider a strategy that refuses, and catches its own error —
460
- the broker hands it back unchanged:
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
+
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
+ `""`.
399
+
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:
461
464
 
462
465
  ```typescript
463
- class LoginRequiredError extends Error {}
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
+ ```
464
477
 
465
- const provider = new AuthorizationCodeProvider({
466
- uaaUrl, clientId, clientSecret, refreshToken,
467
- authorization: {
468
- authorize: async () => {
469
- throw new LoginRequiredError('Run mcp-auth to log in');
470
- },
471
- },
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.
481
+
482
+ **What you meet:**
483
+
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)`.
538
+
539
+ ### How the Client Authenticates: `clientAuthentication`
540
+
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:
547
+
548
+ ```typescript
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';
552
+
553
+ const x509Broker = new AuthBroker({
554
+ serviceKeyStore: new XsuaaServiceKeyStore('/path/to/keys', {
555
+ grantType: 'client_credentials',
556
+ }),
557
+ sessionStore: new XsuaaSessionStore('/path/to/sessions'),
558
+ renewal: () => refreshThenLogin(),
559
+ onWriteFailure: 'fail',
560
+ // The key's certificate, presented at <certurl>/oauth/token.
561
+ clientAuthentication: fromServiceKeyCertificate(),
472
562
  });
473
563
 
474
- try {
475
- await broker.getToken('TRIAL');
476
- } catch (error) {
477
- if (error instanceof LoginRequiredError) {
478
- // No usable token or refresh token: a person has to log in.
479
- }
480
- }
564
+ const certificateProvider = await x509Broker.getProvider('mcp');
565
+ ```
566
+
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.
584
+
585
+ **The two shipped factories** each fail closed:
586
+
587
+ | Factory | Answers | Refuses |
588
+ |---|---|---|
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" |
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" |
591
+
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:
596
+
597
+ ```typescript
598
+ import {
599
+ type ClientAuthenticationStrategy,
600
+ fromServiceKeyCertificate,
601
+ fromServiceKeySecret,
602
+ } from '@mcp-abap-adt/auth-broker';
603
+
604
+ // The certificate when the key holds one, else the secret in a Basic header.
605
+ const certificateElseSecret: ClientAuthenticationStrategy = async (context) =>
606
+ (await context.readCertificate())
607
+ ? fromServiceKeyCertificate()(context)
608
+ : fromServiceKeySecret({ encoding: 'raw' })(context);
609
+
610
+ // The certificate for client_credentials only; every other grant its secret.
611
+ const byGrant: ClientAuthenticationStrategy = (context) =>
612
+ context.grant === 'client_credentials'
613
+ ? fromServiceKeyCertificate()(context)
614
+ : fromServiceKeySecret({ encoding: 'raw' })(context);
615
+ ```
616
+
617
+ **Where the certificate comes from** — `IServiceKeyStore.getClientCertificate?`
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.
629
+
630
+ ```env
631
+ # A certificate destination — EnvDestinationStore (ABAP_DESTINATION_VARS)
632
+ SAP_AUTH_TYPE=jwt
633
+ SAP_GRANT_TYPE=client_credentials
634
+ SAP_URL=https://your-system.abap.us10.hana.ondemand.com
635
+ SAP_UAA_URL=https://your-account.authentication.us10.hana.ondemand.com
636
+ SAP_UAA_CLIENT_ID=sb-your-app!t12345
637
+ SAP_UAA_CLIENT_CERT_PATH=/home/you/keys/client.crt
638
+ SAP_UAA_CLIENT_KEY_PATH=/home/you/keys/client.key
639
+ SAP_UAA_CERT_URL=https://your-account.authentication.cert.us10.hana.ondemand.com
481
640
  ```
482
641
 
483
- A cached token that is still valid, or a refresh token the UAA accepts, never
484
- reaches the strategy.
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`.
646
+
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.
654
+
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:
659
+
660
+ | Words (after `Destination "<name>": `) | When |
661
+ |---|---|
662
+ | `the clientAuthentication strategy refused: the destination has no client certificate` | `fromServiceKeyCertificate()`, no certificate client |
663
+ | `the clientAuthentication strategy refused: the destination has no client secret` | `fromServiceKeySecret()`, no secret client or an empty secret |
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 |
665
+ | `the clientAuthentication strategy answered no client authentication` | the answer has no `authenticate` function |
666
+ | `the client certificate could not be read` | the store failed reading the certificate client for the row's identity |
667
+ | `the client certificate the key store answered holds no certificate` | a certificate client whose `certificate` is not a string |
668
+
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.
671
+
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.
677
+
678
+ ### Headless Processes (No Browser)
485
679
 
486
- With `getProvider` the same holds for every collaborator option: give the
487
- broker an `authorization` / `oidcAuthorization` that refuses, and a
488
- `deviceCodePresenter` that routes the code to wherever a person can see it —
489
- or refuses. The provider then runs on its stored secret and refresh token;
490
- when a login is needed, `prepare()` / `rejected()` answer Oops — whose
491
- refusal carries fixed wording, never the message of what your collaborator
492
- threw — and nothing is written:
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:
493
683
 
494
684
  ```typescript
685
+ import { AuthBroker } from '@mcp-abap-adt/auth-broker';
686
+ import { refreshThenLogin } from '@mcp-abap-adt/auth-providers';
687
+
688
+ class LoginRequiredError extends Error {}
689
+
495
690
  const refuseLogin = {
496
- authorize: async () => {
691
+ authorize: async (): Promise<never> => {
497
692
  throw new LoginRequiredError('Run mcp-auth to log in');
498
693
  },
499
694
  };
500
695
 
501
- const broker = new AuthBroker({
696
+ const headless = new AuthBroker({
502
697
  serviceKeyStore: myKeyStore,
503
698
  sessionStore: mySessionStore,
699
+ renewal: () => refreshThenLogin(),
700
+ onWriteFailure: 'continue',
504
701
  authorization: () => refuseLogin,
505
702
  oidcAuthorization: () => refuseLogin,
506
703
  deviceCodePresenter: () => ({
@@ -511,207 +708,284 @@ const broker = new AuthBroker({
511
708
  });
512
709
  ```
513
710
 
514
- ### 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.
515
715
 
516
- How a login is conducted — including which port the local OAuth2 callback
517
- listens on — is an `IAuthorizationStrategy` passed as `authorization`, not a
518
- field on the provider config. `browserCallbackStrategy` from
519
- `@mcp-abap-adt/auth-providers` builds the ready-made one; its default port is
520
- `61001`, chosen to sit well clear of the range application servers and
521
- proxies typically use (e.g. `3001`/`3333`). Pass `port` to avoid conflicts
522
- 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:
523
723
 
524
724
  ```typescript
525
- new AuthorizationCodeProvider({
526
- uaaUrl, clientId, clientSecret,
527
- authorization: browserCallbackStrategy({ browser: 'system', port: 4001 }),
528
- });
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 });
529
730
  ```
530
731
 
531
- ### 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
532
736
 
533
737
  ```typescript
534
- // 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.
535
739
  const token = await broker.getToken('TRIAL');
536
740
 
537
741
  // A new token, never the cached one — after the server refused the token (401).
538
- const newToken = await broker.refreshToken('TRIAL');
742
+ const newToken = await broker.refreshToken('TRIAL', { signal });
743
+ ```
744
+
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
+ });
539
783
  ```
540
784
 
541
- **Which provider answers.**
542
-
543
- - **No `provider` option** — the destination's own: the very provider
544
- `getProvider('TRIAL')` hands out, from the same per-destination cache. A
545
- connector and the token API in one process therefore share one token, one
546
- refresh token and one renewal: a `refreshToken()` while the connector renews
547
- in `rejected()` joins that renewal, and a token the connector renewed is
548
- what `getToken()` answers next. The destination must state a grant that
549
- obtains a token — every grant but `none` (see the table under *A Provider
550
- for a Connector*); a `none` destination is a `DestinationConfigError`
551
- naming `provider`, before any provider is built.
552
- - **A `provider` option** — yours, as in 3.x (see *Basic Usage*): the factory
553
- is called once per destination (concurrent first calls build once; a call
554
- after one that threw builds again), an instance serves every destination.
555
-
556
- **What is written.** The session secret alone, in one `saveSession` — the
557
- token, or the cookies of a SAML result (`tokenType: 'saml'`), its
558
- `expiresAt`, the refresh token (the stored one carried forward when the
559
- result has none), and what the secret is bound to (`issuedFor`, `issuedBy`,
560
- see *A Secret Is Bound to Its Resource and Issuer*). Never `serviceUrl`,
561
- `authType` or a client: they are means, and live in the key store.
562
-
563
- - Without a `provider`, the token API writes nothing itself: the provider's
564
- `onTokens` writes every token it obtains (see *Persistence and `flush()`*),
565
- and a cache hit writes nothing.
566
- - With a `provider`, the token API writes after every answer, cache hits
567
- included, through the same write path. `issuedFor` is the `serviceUrl` it
568
- resolved, with the SAP client; `issuedBy` the client a factory was handed —
569
- an instance is handed none, so what it obtains is bound to no issuer and
570
- `getProvider` never seeds from it. Both are fixed when your provider is
571
- built for the destination (an instance: at its first call for it) and kept
572
- with it: a URL or client changed later never re-labels a token that
573
- provider obtained — a new broker picks the change up.
574
-
575
- **A write that fails** reaches the caller: `getToken()` / `refreshToken()`
576
- throw the store's error, as raised, for the token they received — and the
577
- broker keeps retrying that write on its own; `flush()` tells you when it
578
- landed.
579
-
580
- **A destination stated `basic` or `snc` has no token API.** The token API
581
- reads the destination's `authType` from the key store first and, for `basic`
582
- or `snc`, throws `DestinationConfigError` naming `authType` before any provider
583
- is asked — a token written over such a session would replace a credential
584
- that is not one. A key store that states no `authType` (a 3.x setup), or no
585
- key store, is served as before.
586
-
587
- **Two token sources, with a `provider` option.** `getProvider` never uses
588
- the `provider` option: the destination states its own provider. A process
589
- that gives the broker a `provider` and also calls `getProvider` for the same
590
- destination has two token sources for it — each with its own token and
591
- renewal, both writing to the same session. Use one of them per destination;
592
- or, to hand your own provider to a connector, wrap the token API:
593
- `TokenAuthProvider.from(broker.createTokenRefresher('TRIAL'))`
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'))`
594
811
  (`@mcp-abap-adt/auth-providers`), whose every renewal the token API writes.
595
812
 
596
- ### Creating Token Refresher for DI
813
+ ### Creating a Token Refresher for DI
597
814
 
598
- `createTokenRefresher(destination)` returns an `ITokenRefresher` (from
815
+ `createTokenRefresher(destination, { signal? })` returns an `ITokenRefresher` (from
599
816
  `@mcp-abap-adt/interfaces-auth`) bound to one destination — `getToken()` is
600
- `broker.getToken(destination)`, `refreshToken()` is
601
- `broker.refreshToken(destination)` — for a connection of your own that asks
602
- for a token per request and for a new one after a 401. It is unchanged since
603
- 3.x and not deprecated: calm-server injects it into its own connection.
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.
604
820
 
605
821
  ```typescript
606
- const tokenRefresher = broker.createTokenRefresher('TRIAL');
822
+ const tokenRefresher = broker.createTokenRefresher('TRIAL', { signal: session.signal });
607
823
 
608
- // In your own connection:
609
- const token = await tokenRefresher.getToken();
824
+ const current = await tokenRefresher.getToken();
610
825
  // …the server answered 401:
611
- const newToken = await tokenRefresher.refreshToken();
826
+ const renewed = await tokenRefresher.refreshToken();
612
827
  ```
613
828
 
614
- A `@mcp-abap-adt/connection` 10 connector takes an `IAuthProvider` instead:
615
- give it `await broker.getProvider('TRIAL')`.
616
-
617
- ## Migrating from 3.x
618
-
619
- 4.0.0 is a major: what a 3.x consumer must do, in short (the full list is the
620
- [CHANGELOG](CHANGELOG.md)'s 4.0.0 entry).
621
-
622
- 1. **Session store: `@mcp-abap-adt/auth-stores` 3 or later** (`^3.1.0` for
623
- the binding), or one of your own on the
624
- `@mcp-abap-adt/interfaces-auth-broker` contract. The broker writes the
625
- secret alone — token or cookies, `expiresAt`, refresh token, `issuedFor`,
626
- `issuedBy` — and auth-stores 1.x/2.x's session stores refuse such a write
627
- (their `AbapSessionStore` throws without `serviceUrl`): they are not
628
- supported. A custom store must keep `issuedFor` and `issuedBy`.
629
- 2. **State the means in a key store**, not in the session: `authType`, and
630
- for `jwt` / `saml` a `grantType`; the client; the URL. For a SAP service
631
- key, `new AbapServiceKeyStore(dir, { grantType: 'authorization_code' })`
632
- (or `XsuaaServiceKeyStore`); otherwise `EnvDestinationStore(dir, {
633
- fallback })`. A 3.x session file is a readable destination as it is once
634
- it states `SAP_AUTH_TYPE` (and `SAP_GRANT_TYPE` for `jwt` / `saml`).
635
- 3. **A connector of `@mcp-abap-adt/connection` 10** takes `await
636
- broker.getProvider(destination)` — no `getToken` before connecting, no
637
- token refresher. Give the broker the collaborators its grants need
638
- (`authorization`, `oidcAuthorization`, `deviceCodePresenter`,
639
- `samlCookies`, `assertionReplayStore`); there is no default.
640
- 4. **The token API** keeps its calls, signatures and 3.x reads with a
641
- `provider` of yours; it writes the secret alone, refuses a destination
642
- stated `basic` or `snc`, and throws a failed write while the broker retries
643
- it.
644
- 5. **`getConnectionConfig` / `getAuthorizationConfig`** compose the key
645
- store's means with the session's secret; a URL or client kept only in the
646
- session store is not found any more.
647
- 6. **New:** catch `DestinationConfigError`; call `flush()` on shutdown;
648
- write `bindingOf(means)` beside a credential you hand over yourself.
649
- 7. **Contracts:** `interfaces-auth` 3, `interfaces-auth-sap` 2,
650
- `interfaces-auth-broker` 1.1 (the store contracts moved there from
651
- `interfaces-auth-sap`), auth-providers 5. The commands are
652
- `@mcp-abap-adt/auth-broker-cli` 2.0.0.
653
-
654
- ## Migrating from 2.2.0
655
-
656
- 1. `tokenProvider` is now `provider`, and the `browser` argument is gone:
657
- `new AuthBroker({ sessionStore, serviceKeyStore, tokenProvider }, 'system', logger)`
658
- becomes `new AuthBroker({ sessionStore, serviceKeyStore, provider }, logger)`.
659
- 2. The provider must implement `IRefreshableTokenProvider` (`refreshTokens()`,
660
- a new token, never the cached one) — `@mcp-abap-adt/auth-providers` 4.2.0
661
- providers do. Pass a factory instead of an instance to have the broker seed
662
- it with the stored refresh token and token.
663
- 3. `allowBrowserAuth: false` and `BROWSER_AUTH_REQUIRED` are gone: give the
664
- provider an authorization strategy that refuses and catch your own error
665
- (see *Headless Processes*). A browser login that fails — timeout, the
666
- identity provider's refusal, a busy callback port — is auth-providers'
667
- `BrowserAuthError` (from 4.2.0).
668
- 4. Provider errors arrive unchanged: match on the class or `code`, not on the
669
- old `Token provider … error for <destination>` messages.
670
- 5. The broker no longer writes the client secret into the session store. Read
671
- it from the service key store if you relied on finding it in the session.
672
- 6. `refreshToken()` now forces a new token; it used to return `getToken()`'s.
673
- 7. Node.js 22, 24 or 26; SAML runs need the IdP trust (see *Migrating `mcp-sso`
674
- 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 })`.
675
831
 
676
- ## Configuration
832
+ ## Errors
677
833
 
678
- ### 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:
679
839
 
680
- The library reads no environment variable: the stores take their directories
681
- from their constructors, and the providers their settings from the
682
- destination's means and the collaborators you give the broker. (In the
683
- repository's test suites, `DEBUG_BROKER=true` — or `DEBUG_AUTH_BROKER=true`,
684
- `DEBUG=broker` — turns on the test logger, its level from `AUTH_LOG_LEVEL`.)
840
+ ```typescript
841
+ import { isDestinationConfigError } from '@mcp-abap-adt/auth-broker';
842
+ import { isAuthProviderFailure, readFailure } from '@mcp-abap-adt/auth-errors';
685
843
 
686
- ### 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
+ ```
687
858
 
688
- Pass an `ILogger` as the constructor's second argument (for instance
689
- `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.
690
862
 
691
- **What is logged:** the broker's initialization (whether a key store is
692
- given; the `provider` option: `none`, `factory` or `instance`), each provider build (the destination's `authType` and grant, whether
693
- it was seeded), each session secret saved (token or cookies, whether a
694
- refresh token came back, the expiry), a stored secret discarded because it is
695
- bound elsewhere (the destination only), and each failed write with the error's
696
- class name.
863
+ **The failures the broker makes itself** are `AuthProviderFailure`s minted by auth-errors, no
864
+ free words of its own:
697
865
 
698
- **What is never logged:** any part of a token, refresh token, password or
699
- secret — not a prefix, not a suffix — nor a store error's message, which may
700
- quote what was being written.
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.
701
973
 
702
- With `DefaultLogger` at `info`:
974
+ ## Configuration
703
975
 
704
- ```
705
- [INFO] ℹ️ [AuthBroker] Session secret saved for TRIAL
706
- {"credential":"token","hasRefreshToken":true,"expiresAt":1790000000000}
707
- ```
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`.)
708
983
 
709
984
  ### File Structure
710
985
 
711
- With `@mcp-abap-adt/auth-stores` 3 one `<destination>.env` file can hold a
712
- destination whole, each store reading and writing only its own keys — the
713
- means through `EnvDestinationStore`, the secret through `AbapSessionStore`
714
- (or both in separate directories, as you compose them):
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):
715
989
 
716
990
  ```env
717
991
  # The means — EnvDestinationStore (ABAP_DESTINATION_VARS); the broker never writes them
@@ -728,21 +1002,23 @@ SAP_JWT_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
728
1002
  SAP_EXPIRES_AT=1790000000000
729
1003
  SAP_REFRESH_TOKEN=refresh_token_string
730
1004
  SAP_ISSUED_FOR=https://your-system.abap.us10.hana.ondemand.com:443?sap-client=100
731
- SAP_ISSUED_BY=https://your-account.authentication.us10.hana.ondemand.com:443?client_id=client_id
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
732
1006
  ```
733
1007
 
734
- SAML cookies are `SAP_SESSION_COOKIES_B64` in place of `SAP_JWT_TOKEN`. The
735
- XSUAA stores use the same names with `XSUAA_` (`XSUAA_DESTINATION_VARS`,
736
- `XSUAA_SESSION_VARS`; the URL is `XSUAA_MCP_URL`). Every means key
737
- (`SAP_USERNAME`, `SAP_SNC_*`, `SAP_OIDC_*`, `SAP_SAML_*`, …) is listed in
1008
+ A client that authenticates with a certificate states `SAP_UAA_CLIENT_CERT_PATH`,
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
738
1014
  [USAGE.md](../../docs/using/USAGE.md#environment-variables).
739
1015
 
740
1016
  #### Service Key File (`{destination}.json`)
741
1017
 
742
- A SAP service key, read by `AbapServiceKeyStore` (an ABAP environment key:
743
- the client and the ABAP URL) or `XsuaaServiceKeyStore` (an XSUAA key: the
744
- client). A key cannot state which grant the destination uses, so the store is
745
- told: `new AbapServiceKeyStore(dir, { grantType: 'authorization_code' })`.
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' })`.
746
1022
 
747
1023
  ```json
748
1024
  {
@@ -755,372 +1031,217 @@ told: `new AbapServiceKeyStore(dir, { grantType: 'authorization_code' })`.
755
1031
  }
756
1032
  ```
757
1033
 
758
- An XSUAA key carries no URL of the resource it authorizes for: state it as
759
- means in an `EnvDestinationStore` (`XSUAA_MCP_URL`, with
760
- `XSUAA_DESTINATION_VARS`) whose `fallback` is the `XsuaaServiceKeyStore`.
761
-
762
- ## Responsibilities and Design Principles
763
-
764
- ### Core Development Principle
765
-
766
- **Interface-Only Communication**: This package follows a fundamental development principle: **all interactions with external dependencies happen ONLY through interfaces**. The code knows **NOTHING beyond what is defined in the interfaces**.
767
-
768
- This means:
769
- - Does not know about concrete store classes (e.g., `AbapSessionStore`); the provider classes it knows only to construct the one a destination states, and hands out as `IAuthProvider`
770
- - Does not know about internal data structures or methods not defined in interfaces
771
- - Does not make assumptions about implementation behavior beyond interface contracts
772
- - Does not access properties or methods not explicitly defined in interfaces
773
-
774
- This principle ensures:
775
- - **Loose coupling**: `AuthBroker` is decoupled from concrete implementations
776
- - **Flexibility**: New implementations can be added without modifying `AuthBroker`
777
- - **Testability**: Easy to mock dependencies for testing
778
- - **Maintainability**: Changes to implementations don't affect `AuthBroker`
779
-
780
- ### Package Responsibilities
781
-
782
- 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`).
783
-
784
- #### What AuthBroker Does
785
-
786
- - **Resolves what the stores hold**: the means from the key store, the secret from the session store (and, for a `provider` of yours, the 3.x reads: the service URL and the UAA credentials, session first)
787
- - **Builds or reuses the provider**: one per destination — the one the destination states, shared by `getProvider` and the token API; or your factory, called once per destination
788
- - **Asks the provider once**: `getTokens()` for `getToken()`, `refreshTokens()` for `refreshToken()` — no retries, no fallbacks
789
- - **Persists the answer**: the session secret alone — token or session cookies, `expiresAt`, the refresh token, and what it is bound to — to `sessionStore`, retrying a write that fails
790
- - **Works with interfaces only**: `IServiceKeyStore`, `ISessionStore`, `IAuthProvider`, `IRefreshableTokenProvider`
791
-
792
- #### What AuthBroker Does NOT Do
793
-
794
- - **Does NOT implement storage**: File I/O, parsing, and storage logic are handled by concrete store implementations from `@mcp-abap-adt/auth-stores`
795
- - **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`
796
- - **Does NOT judge the token**: whether a cached token is still valid, and whether to refresh or log in, is the provider's decision
797
- - **Does NOT decide how a login is conducted**: browser, headless or pasted code is the provider's authorization strategy
798
- - **Does NOT copy secrets**: the client secret stays in the service key store
799
-
800
- ### Consumer Responsibilities
801
-
802
- The **consumer** (application using `AuthBroker`) is responsible for:
803
-
804
- 1. **Composing the stores and collaborators**: an `IServiceKeyStore` for the means and an `ISessionStore` for the secret — with auth-stores 3:
805
- - **ABAP systems (a SAP service key)**: `AbapServiceKeyStore(dir, { grantType })`, alone or as the `fallback` of an `EnvDestinationStore`, and `AbapSessionStore` (or `SafeAbapSessionStore`, in memory)
806
- - **Destinations without a key** (basic, SNC, OIDC, SAML, `none`): `EnvDestinationStore(dir)` and `AbapSessionStore`
807
- - **XSUAA services**: `EnvDestinationStore(dir, { variables: XSUAA_DESTINATION_VARS, fallback: new XsuaaServiceKeyStore(keys, { grantType }) })` and `XsuaaSessionStore` (or `SafeXsuaaSessionStore`)
808
- - the collaborator options the destinations' grants need — or, for the token API the 3.x way, an `IRefreshableTokenProvider` of your own
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
1037
+ certificate client instead of a secret — the broker uses it only beside a
1038
+ `clientAuthentication` strategy:
809
1039
 
810
- 2. **Stating the means in the key store**: `authType`, `grantType` and the fields its row reads, the client, and `serviceUrl` — for the token API with a `provider` of yours, the service URL comes from the session, else the key store.
811
-
812
- 3. **A session store that takes the secret alone**: the broker writes the token or cookies, `expiresAt`, the refresh token, `issuedFor` and `issuedBy` in one `saveSession`, and nothing else. auth-stores 3's session stores do; auth-stores 1.x/2.x's `AbapSessionStore` and `SafeAbapSessionStore` refuse a write without `serviceUrl` (measured on 1.2.3, 1.2.4, 2.0.0), so a token API on them throws after every token. A custom store must also keep `issuedFor` and `issuedBy`.
813
-
814
- ### Store Responsibilities
815
-
816
- Concrete `ISessionStore` implementations are responsible for:
817
-
818
- - **Handling their own data format**: Each store knows its internal data format (e.g., `AbapSessionData`, `BtpBaseSessionData`)
819
- - **Converting between formats**: Converting between `IConfig`/`IConnectionConfig` and internal storage format
820
- - **Taking the secret alone**: a session store is written the secret and its binding only, and keeps what it held beside them (auth-stores 3 refuses means outright)
821
-
822
- ### Provider Responsibilities
1040
+ ```json
1041
+ {
1042
+ "url": "https://your-account.authentication.us10.hana.ondemand.com",
1043
+ "clientid": "sb-your-app!t12345",
1044
+ "certificate": "-----BEGIN CERTIFICATE-----\n...",
1045
+ "key": "-----BEGIN RSA PRIVATE KEY-----\n...",
1046
+ "certurl": "https://your-account.authentication.cert.us10.hana.ondemand.com",
1047
+ "credential-type": "x509"
1048
+ }
1049
+ ```
823
1050
 
824
- Concrete `IRefreshableTokenProvider` implementations are responsible for:
1051
+ ## Responsibilities
825
1052
 
826
- - **Obtaining tokens**: Using OAuth2 flows, refresh tokens, or client credentials to obtain JWT tokens
827
- - **Managing token lifecycle**: Caching, validating, refreshing, and re-authenticating as needed (`getTokens()`)
828
- - **Forcing a new token**: `refreshTokens()` — never the cached one
829
- - **Reporting failures with typed errors**, which the broker passes on unchanged
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`.
830
1060
 
831
- ### Design Principles
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).
832
1066
 
833
- 1. **Interface-Only Communication** (Core Principle): All interactions with external dependencies happen **ONLY through interfaces**. The code knows **NOTHING beyond what is defined in the interfaces** (see [Core Development Principle](#core-development-principle) above)
834
- 2. **Dependency Inversion Principle (DIP)**: `AuthBroker` depends on abstractions (`IServiceKeyStore`, `ISessionStore`, `IAuthProvider`, `IRefreshableTokenProvider`), not concrete store implementations; the providers it builds it hands out as `IAuthProvider`
835
- 3. **Single Responsibility**: Each component has a single, well-defined responsibility:
836
- - `AuthBroker`: Orchestration — resolving, asking, persisting
837
- - `ISessionStore`: Session data storage and retrieval
838
- - `IRefreshableTokenProvider`: Token acquisition and lifecycle
839
- - `IServiceKeyStore`: Service key storage and retrieval
840
- 4. **Interface Segregation**: Interfaces are focused and minimal, containing only what's necessary for their specific purpose
841
- 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`.
842
1070
 
843
1071
  ## API
844
1072
 
845
- ### `AuthBroker`
846
-
847
- #### Constructor
848
-
849
1073
  ```typescript
850
- new AuthBroker(
851
- config: {
852
- sessionStore: ISessionStore; // required
853
- serviceKeyStore?: IServiceKeyStore; // getProvider needs it
854
- provider?: // the token API's, optional
855
- | IRefreshableTokenProvider
856
- | ((
857
- destination: string,
858
- authConfig: IAuthorizationConfig | null,
859
- connConfig: IConnectionConfig,
860
- ) => IRefreshableTokenProvider);
861
- // Collaborators, each a function of the destination, called once per
862
- // build, never disposed by the broker:
863
- authorization?: (destination: string, grant: StrategyGrant) => IAuthorizationStrategy<string>; // authorization_code, passcode, saml2_pure, saml2_bearer
864
- oidcAuthorization?: (destination: string) => IAuthorizationStrategy<OidcCallbackResult>; // oidc_authorization_code
865
- deviceCodePresenter?: (destination: string) => IDeviceCodePresenter; // device_code
866
- samlCookies?: (destination: string) => (samlResponse: string) => Promise<string>; // saml2_pure
867
- assertionReplayStore?: (destination: string) => IAssertionReplayStore; // saml2_pure, saml2_bearer
868
- },
869
- logger?: ILogger,
870
- )
871
- ```
872
-
873
- **Parameters:**
874
- - `config.sessionStore` - **Required** - The session secret: the token or cookies, `expiresAt`, the refresh token, and what it is bound to (`issuedFor`, `issuedBy`). For the token API with a `provider`, its `serviceUrl`, or the service key's, is required (3.x's reads).
875
- - `config.serviceKeyStore` - The means: `authType`, `grantType`, the client, basic's user and password, the SNC, OIDC and SAML fields, `serviceUrl`. **Required by `getProvider`**, which has no other source of means.
876
- - `config.provider` - The token API's source (`getToken`, `refreshToken`, `createTokenRefresher`): a provider instance, used for every destination, or a factory (`TokenProviderFactory`), called once per destination and seeded with what the stores hold (see *Basic Usage*). Not used by `getProvider`. Without it, the token API asks the provider `getProvider` builds for the destination (see *Getting Tokens*); with neither it nor a `serviceKeyStore`, the token API throws `DestinationConfigError` naming both.
877
- - `config.authorization` - The interactive strategy of `jwt` / `authorization_code`, `jwt` / `passcode`, `saml` / `saml2_pure` and `saml` / `saml2_bearer`, as a function of the destination and the grant (see *A Provider for a Connector*).
878
- - `config.oidcAuthorization` - The interactive strategy of `jwt` / `oidc_authorization_code`.
879
- - `config.deviceCodePresenter` - Where `jwt` / `device_code` shows the user the verification URL and code.
880
- - `config.samlCookies` - `saml2_pure`: turns the validated SAMLResponse into the system's session cookies.
881
- - `config.assertionReplayStore` - The replay store the SAML validators record each assertion in.
882
- - Each collaborator option is required only by the rows that use it (missing → `DestinationConfigError` naming it); the broker supplies no default, calls it once per build, and disposes nothing it returns.
883
- - `logger` - Optional logger. If not provided, nothing is logged.
884
-
885
- **Available Implementations:**
886
- - **Means** (auth-stores 3): `EnvDestinationStore(directory, { fallback? })`, `AbapServiceKeyStore(directory, { grantType?, log? })`, `XsuaaServiceKeyStore(directory, { grantType?, log? })`
887
- - **Secret** (auth-stores 3): `AbapSessionStore(directory, log?)`, `SafeAbapSessionStore(log?)`, `XsuaaSessionStore(directory, log?)`, `SafeXsuaaSessionStore(log?)`, `EnvFileSessionStore(file, log?)`
888
- - **Providers** (auth-providers 5): `AuthorizationCodeProvider(...)`, `ClientCredentialsProvider(...)` and the rest, for a `provider` of yours
889
-
890
- #### Methods
891
-
892
- ##### `getProvider(destination: string): Promise<IAuthProvider>`
893
-
894
- The `IAuthProvider` the destination states, built from the key store's means
895
- and the session store's secret (see *A Provider for a Connector*). Cached per
896
- destination; a failed build is retried on the next call. Throws
897
- `DestinationConfigError` for a destination that lacks what its type needs, and
898
- passes a store failure other than absence on as the store raised it.
899
-
900
- ##### `flush(): Promise<void>`
901
-
902
- Every session write still pending gets one more attempt. Resolves when all
903
- have landed; rejects with an `AggregateError` naming the destinations whose
904
- store still refuses (its `errors` carry each destination and the store
905
- error's class only, never its message) — the broker keeps retrying them. See *Persistence and `flush()`*.
906
-
907
- ##### `getToken(destination: string): Promise<string>`
908
-
909
- 1. Reads the destination's `authType` from the key store: `basic` or `snc` is a `DestinationConfigError` naming `authType`, before any provider is asked.
910
- 2. **Without a `provider` option:** takes the provider `getProvider(destination)` hands out (the same cache; a `none` destination is a `DestinationConfigError` naming `provider`), calls `getTokens()` once, and writes nothing itself — the provider's `onTokens` wrote anything new.
911
- 3. **With one:** resolves `serviceUrl` (session, else service key; an error if neither has one), builds the provider on first use (factory form) seeded with the credentials, the stored refresh token and the stored token — or uses the instance — calls `getTokens()` once, and writes the result: the secret alone, through the broker's write path.
912
- 4. Throws the store's error if the write of this token failed (the broker keeps retrying it); else returns the token.
913
-
914
- ##### `refreshToken(destination: string): Promise<string>`
915
-
916
- The same, with `provider.refreshTokens()`: a new token, never the cached one —
917
- for a caller whose token the server has just refused. A renewal already in
918
- flight for the destination (a connector's, in `rejected()`) is joined.
919
-
920
- ##### `getAuthorizationConfig(destination)` / `getConnectionConfig(destination)`
921
-
922
- The two stores composed, each for its role:
923
-
924
- - `getConnectionConfig` — the service key store's means with the session's
925
- secret (`authorizationToken`, `sessionCookies`, `expiresAt`) laid over them;
926
- `null` when neither store holds anything.
927
- - `getAuthorizationConfig` — the service key store's client (`uaaUrl`,
928
- `uaaClientId`, `uaaClientSecret`) with the session's refresh token; `null`
929
- when the key store has no client.
930
-
931
- Means a session store answers (a client, a URL, an `authType`) are not read,
932
- nor a secret a key store answers. Up to 3.1.0 both answered the session's
933
- configuration whole, and the key's only when the session had none.
934
-
935
- ##### `createTokenRefresher(destination): ITokenRefresher`
936
-
937
- `getToken()` and `refreshToken()` bound to one destination, for injection into a connection of your own. Unchanged since 3.x; not deprecated.
938
-
939
- ##### Error Handling
940
-
941
- - **`DestinationConfigError`** — a destination that lacks what its type needs.
942
- `getProvider` throws it before any provider is asked; so does the token
943
- API, for the same destinations and for those below. It carries `code:
944
- 'DESTINATION_CONFIG'`, `destination`, `missingFields` — field or option
945
- names only. When a provider's constructor refused (an `sncQop` outside `1`,
946
- `2`, `3`, `8`, `9`), the store field is named and the provider's own error
947
- is not kept — its message quotes the value. **No stored value
948
- reaches it**: not a password, not a token, not an `authType` that is none of
949
- the four. Thrown for:
950
-
951
- | Case | `missingFields` |
952
- |---|---|
953
- | no `serviceKeyStore` option (`getProvider`) | `serviceKeyStore` |
954
- | the token API with neither a `provider` nor a `serviceKeyStore` option | `provider`, `serviceKeyStore` |
955
- | the token API on a destination stated `basic` or `snc` | `authType` |
956
- | the token API without a `provider` option, on a `jwt` / `none` or `saml` / `none` destination | `provider` |
957
- | the key store has no means for the destination — whatever the session holds | `authType` |
958
- | no `authType`, `''`, or one that is not `basic`, `jwt`, `saml`, `snc` | `authType` |
959
- | `jwt` / `saml` without `grantType`, `''`, or a pair outside the table | `grantType` |
960
- | `basic` without user or password (`''` counts as missing) | `username`, `password` — each that is missing |
961
- | `snc` without `sncPartnerName` | `sncPartnerName` |
962
- | `snc` whose settings the provider refuses | the store field, e.g. `sncQop` |
963
- | `jwt` / `none` without a token in the session | `authorizationToken` |
964
- | `saml` / `none` without cookies in the session | `sessionCookies` |
965
- | `jwt` / `none`, `saml` / `none` whose stored `issuedFor` is not the destination's resource, or is absent, or the means state no `serviceUrl` | `issuedFor` |
966
- | `jwt` / `none`, `saml` / `none` whose means state an issuer and whose stored `issuedBy` is not it, or is absent | `issuedBy` |
967
- | a UAA grant without its client in the key store (`''` counts as missing) | `uaaUrl`, `uaaClientId`, and `uaaClientSecret` for `authorization_code` / `client_credentials` — each that is missing |
968
- | `authorization_code` / `passcode` without the `authorization` option | `authorization` |
969
- | an OIDC grant without its client's id in the key store | `uaaClientId` |
970
- | an OIDC grant with neither `oidcIssuerUrl` nor every endpoint its row reads | `oidcIssuerUrl`, and each endpoint missing: `oidcAuthorizationEndpoint` / `oidcDeviceAuthorizationEndpoint`, `oidcTokenEndpoint` |
971
- | `password` without user or password | `username`, `password` — each that is missing |
972
- | `token_exchange` without its subject | `oidcSubjectToken`, `oidcSubjectTokenType` — each that is missing |
973
- | `oidc_authorization_code` without the `oidcAuthorization` option | `oidcAuthorization` |
974
- | `device_code` without the `deviceCodePresenter` option | `deviceCodePresenter` |
975
- | a SAML grant without its trust (`''` and an empty certificate list count as missing) | `samlIdpSsoUrl`, `samlSpEntityId`, `samlIdpEntityId`, `samlIdpCertificates` — each that is missing |
976
- | `saml2_bearer` without its client | `uaaUrl`, `uaaClientId` — each that is missing |
977
- | a SAML grant without its collaborators | `authorization`, `samlCookies` (`saml2_pure`), `assertionReplayStore` — each that is missing |
978
- | a certificate the validator cannot read | `samlIdpCertificates` |
979
- | a `samlClockSkewMs` that is not a whole, non-negative number | `samlClockSkewMs` |
980
-
981
- - **Provider errors propagate unchanged** — the same object, with its class,
982
- `code`, `missingFields` and `cause`: auth-providers' `ValidationError`,
983
- `BrowserAuthError`, `AssertionValidationError`, network errors (`ECONNREFUSED`,
984
- `ETIMEDOUT`, `ENOTFOUND`), and whatever your authorization strategy throws.
985
- The broker does not retry a failed call.
986
- - **Store reads: absence is an answer, a failure is not.** A store that answers
987
- `null`, or fails with `FILE_NOT_FOUND` (logged at debug), means "nothing
988
- here", and the broker goes on to the next source. Any other store failure —
989
- a service key that is not valid JSON, a file the process may not read — is
990
- thrown unchanged. A failure in the reads that come before the token (the
991
- session's connection config, the service key) stops the call before the
992
- provider is asked; one in the reads that save it (the session's
993
- authorization config, the session) arrives after the provider answered and
994
- the new token was written. (The session stores of
995
- auth-stores answer an unreadable session file with `null` themselves, so it
996
- reads as absent before the broker sees it.)
997
- - **Store writes**: a write that fails reaches the token API's caller — the
998
- store's error, as raised, for the token it received — and is retried by the
999
- broker all the same. A `getProvider` provider's write never fails the
1000
- authentication; it is retried, and `flush()` reports it.
1001
- - **A provider result without a token** is an error.
1002
-
1003
- ```typescript
1004
- import { DestinationConfigError } from '@mcp-abap-adt/auth-broker';
1005
- import { ValidationError } from '@mcp-abap-adt/auth-providers';
1006
-
1007
- try {
1008
- const provider = await broker.getProvider('TRIAL');
1009
- } catch (error) {
1010
- if (error instanceof DestinationConfigError) {
1011
- logger.error(`${error.destination} lacks: ${error.missingFields.join(', ')}`);
1012
- }
1013
- throw error;
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
1014
1109
  }
1015
1110
 
1016
- try {
1017
- const token = await broker.getToken('TRIAL');
1018
- } catch (error) {
1019
- if (error instanceof ValidationError) {
1020
- logger.error(`Missing: ${error.missingFields?.join(', ')}`);
1021
- }
1022
- 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>;
1023
1120
  }
1024
- ```
1025
-
1026
- #### Secrets in the Session Store
1027
-
1028
- Everything the broker writes — a `getProvider` provider's token and the
1029
- token API's alike — is the secret alone: `authorizationToken` (or
1030
- `sessionCookies`), `expiresAt`, `refreshToken`, with `issuedFor` and
1031
- `issuedBy`, in one `saveSession`, and nothing else (see *Persistence and
1032
- `flush()`*). Never the client secret, never `serviceUrl` or `authType`:
1033
- `setConnectionConfig` and `setAuthorizationConfig` are not called. A store
1034
- that merges a write into what it holds keeps a client or URL it held beside
1035
- the secret; auth-stores 3 holds the secret alone.
1036
-
1037
- The stored refresh token comes back through `loadSession()`, which the broker
1038
- reads to seed the next process's provider.
1039
-
1040
- ### Token Providers
1041
-
1042
- Provider implementations live in `@mcp-abap-adt/auth-providers` 5. `getProvider`
1043
- builds the one the destination states (see *A Provider for a Connector*); for
1044
- the token API the 3.x way you pass one yourself — an `IRefreshableTokenProvider`
1045
- or a factory:
1046
-
1047
- - **`ClientCredentialsProvider`** — `client_credentials`: no user, no browser,
1048
- no refresh token.
1049
- - **`AuthorizationCodeProvider`** — `authorization_code`: refreshes by the
1050
- stored refresh token, and logs in through the `authorization` strategy you
1051
- give it — there is no default. `browserCallbackStrategy({ browser?, port?,
1052
- timeoutMs? })` builds the usual one (callback port `61001` unless `port` is
1053
- given; held only for the duration of a login).
1054
-
1055
- ```typescript
1056
- import { AuthBroker } from '@mcp-abap-adt/auth-broker';
1057
- import {
1058
- XsuaaServiceKeyStore,
1059
- XsuaaSessionStore,
1060
- } from '@mcp-abap-adt/auth-stores';
1061
- import { ClientCredentialsProvider } from '@mcp-abap-adt/auth-providers';
1062
1121
 
1063
- // XSUAA, client_credentials: credentials from the service key
1064
- const xsuaaBroker = new AuthBroker({
1065
- sessionStore: new XsuaaSessionStore('/path/to/sessions'),
1066
- serviceKeyStore: new XsuaaServiceKeyStore('/path/to/keys'),
1067
- provider: (destination, authConfig) => {
1068
- if (!authConfig) throw new Error(`No UAA credentials for ${destination}`);
1069
- return new ClientCredentialsProvider({
1070
- uaaUrl: authConfig.uaaUrl,
1071
- clientId: authConfig.uaaClientId,
1072
- clientSecret: authConfig.uaaClientSecret,
1073
- });
1074
- },
1075
- });
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;
1076
1128
  ```
1077
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
+
1078
1206
  ## Testing
1079
1207
 
1080
- The library's tests are in `packages/auth-broker/src/__tests__/` and use Jest.
1081
- Run them from the repository root, where the dev dependencies are installed:
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`):
1082
1211
 
1083
1212
  ```bash
1084
- # Every workspace's tests (library and CLI)
1085
- npm test
1086
-
1087
- # The library's only
1088
1213
  npm test -w @mcp-abap-adt/auth-broker
1089
-
1090
- # One file, or one case
1091
- npm test -w @mcp-abap-adt/auth-broker -- AuthBroker.test.ts
1092
1214
  npm test -w @mcp-abap-adt/auth-broker -- AuthBroker.test.ts -t "name of the case"
1093
1215
  ```
1094
1216
 
1095
- Tests run sequentially (`maxWorkers: 1` and `maxConcurrency: 1` in
1096
- `packages/auth-broker/jest.config.js`). `AuthBroker.test.ts` needs nothing;
1097
- `AuthBroker.integration.test.ts` reads real service keys and sessions, and
1098
- every case in it returns early unless the configuration below names a real
1099
- destination.
1100
-
1101
- ### Test Setup
1102
-
1103
- 1. Copy `packages/auth-broker/tests/test-config.yaml.template` to
1104
- `packages/auth-broker/tests/test-config.yaml` (until the workspace layout it
1105
- lived in `tests/` at the repository root; move an existing copy)
1106
- 2. Fill in configuration values (paths, destinations, MCP URL for XSUAA)
1107
- 3. Place service key files in configured `service_keys_dir`:
1108
- - `{destination}.json` for ABAP tests (e.g., `trial.json`)
1109
- - `{btp_destination}.json` for XSUAA tests (e.g., `btp.json`)
1110
-
1111
- Without `test-config.yaml` the template is read, its placeholders disable every
1112
- 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).
1224
+
1225
+ ### The x509 Live Check
1226
+
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.
1113
1238
 
1114
1239
  ## Documentation
1115
1240
 
1116
- Complete documentation is available in the repository's [`docs/`](../../docs/) directory:
1117
-
1118
- - **[Architecture](../../docs/architecture/ARCHITECTURE.md)** - System architecture and design decisions
1119
- - **[Testing](../../docs/development/TESTING.md)** - Where the suites live, what they need, the release checks
1120
- - **[Installation](../../docs/installing/INSTALLATION.md)** - Installation and setup guide
1121
- - **[Usage](../../docs/using/USAGE.md)** - API reference and usage examples
1122
-
1123
- 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).
1124
1245
 
1125
1246
  ## Contributors
1126
1247
 
@@ -1150,4 +1271,3 @@ set of additional permissions over the GPL and cannot be read alone.
1150
1271
  program — importing it, as every consumer of an npm package does — does not put
1151
1272
  your program under the LGPL. What the licence asks is that changes *to this
1152
1273
  library* stay free, and that your users can replace it with their own build.
1153
-