@mcp-abap-adt/auth-broker 3.1.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +531 -0
- package/README.md +1062 -220
- package/dist/AuthBroker.d.ts +249 -30
- package/dist/AuthBroker.d.ts.map +1 -1
- package/dist/AuthBroker.js +681 -115
- package/dist/DestinationConfigError.d.ts +27 -0
- package/dist/DestinationConfigError.d.ts.map +1 -0
- package/dist/DestinationConfigError.js +36 -0
- package/dist/SessionWriter.d.ts +54 -0
- package/dist/SessionWriter.d.ts.map +1 -0
- package/dist/SessionWriter.js +135 -0
- package/dist/binding.d.ts +117 -0
- package/dist/binding.d.ts.map +1 -0
- package/dist/binding.js +223 -0
- package/dist/bindingOf.d.ts +37 -0
- package/dist/bindingOf.d.ts.map +1 -0
- package/dist/bindingOf.js +53 -0
- package/dist/clientAuthentication.d.ts +109 -0
- package/dist/clientAuthentication.d.ts.map +1 -0
- package/dist/clientAuthentication.js +218 -0
- package/dist/destinations.d.ts +164 -0
- package/dist/destinations.d.ts.map +1 -0
- package/dist/destinations.js +473 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -7
- package/dist/providers/ITokenProvider.d.ts +2 -1
- package/dist/providers/ITokenProvider.d.ts.map +1 -1
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.js +2 -2
- package/dist/stores/index.d.ts +2 -2
- package/dist/stores/index.js +2 -2
- package/dist/stores/interfaces.d.ts +4 -3
- package/dist/stores/interfaces.d.ts.map +1 -1
- package/dist/stores/interfaces.js +2 -2
- package/dist/types.d.ts +5 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -1
- package/package.json +16 -8
package/README.md
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
# @mcp-abap-adt/auth-broker
|
|
2
2
|
[](https://stand-with-ukraine.pp.ua)
|
|
3
3
|
|
|
4
|
-
A per-destination
|
|
5
|
-
`TRIAL` —
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
A per-destination credential broker for SAP BTP and ABAP systems. For a destination — a name,
|
|
5
|
+
such as `TRIAL` — `getProvider` builds the `IAuthProvider` the destination states (basic, SNC, a
|
|
6
|
+
UAA, OIDC or SAML grant, or a credential handed over) from the *means* in the service key store
|
|
7
|
+
and the *secret* in the session store, ready for a `@mcp-abap-adt/connection` 10 connector, and
|
|
8
|
+
stores back every token or set of SAML session cookies that provider obtains or renews. The
|
|
9
|
+
token API (`getToken`, `refreshToken`, `createTokenRefresher`) serves whoever wants a token and
|
|
10
|
+
nothing else. It decides nothing about tokens itself: whether the cached token is still good,
|
|
11
|
+
when to refresh and when to log in is the provider's call (`@mcp-abap-adt/auth-providers`), and
|
|
12
|
+
where means and secrets live is the stores' (`@mcp-abap-adt/auth-stores`, or your own).
|
|
13
|
+
|
|
14
|
+
**Upgrading from 3.x?** See [*Migrating from 3.x*](#migrating-from-3x) — 4.0.0 needs a session
|
|
15
|
+
store that takes the secret alone (auth-stores 3 or later), means stated in a key store, and
|
|
16
|
+
the contracts of `interfaces-auth` 3. **On 4.0.0?** 4.1.0 is additive — a client that
|
|
17
|
+
authenticates with an x509 certificate, through the `clientAuthentication` strategy (see
|
|
18
|
+
[*How the Client Authenticates*](#how-the-client-authenticates-clientauthentication)); what a
|
|
19
|
+
4.0.0 consumer may notice is in [*Migrating from 4.0.0*](#migrating-from-400).
|
|
20
|
+
|
|
21
|
+
The `mcp-auth` and `mcp-sso` commands that write destination files are in
|
|
22
|
+
[`@mcp-abap-adt/auth-broker-cli`](../auth-broker-cli/README.md) (2.1.0, on this
|
|
23
|
+
version), in the same repository. Up to 3.0.4 they shipped in this package; from 3.1.0 this package
|
|
14
24
|
has no `bin`. Install the commands with
|
|
15
25
|
`npm i -g @mcp-abap-adt/auth-broker-cli` — after
|
|
16
26
|
`npm uninstall -g @mcp-abap-adt/auth-broker` if you had installed this one
|
|
@@ -18,11 +28,16 @@ globally for them.
|
|
|
18
28
|
|
|
19
29
|
## Features
|
|
20
30
|
|
|
21
|
-
-
|
|
31
|
+
- 🔌 **A credential for a connector**: `getProvider(destination)` builds the `IAuthProvider` the destination states — basic, SNC, the UAA grants (authorization code, client credentials, passcode), the OIDC grants (authorization code with PKCE, device code, password, token exchange), the SAML grants (session cookies, bearer token), or a credential handed over — from the service key store's means and the session store's secret
|
|
32
|
+
- 💾 **What a provider obtains is stored**: every token or set of SAML session cookies a `getProvider` provider obtains or renews — at `prepare()`, on expiry, or after a 401 in `rejected()` — is written to the session store, the secret alone with what it is bound to; a failed write is retried by the broker, and `flush()` tells you whether everything landed
|
|
33
|
+
- 🔒 **A secret goes only where it was obtained**: a stored token is used only for the resource it was obtained for, from the issuer and client that issued it — otherwise it is discarded and the provider logs in afresh
|
|
34
|
+
- 🎯 **Per destination**: one provider per destination name, shared by `getProvider` and the token API — one token, one refresh token, one renewal in flight
|
|
22
35
|
- 🔄 **Provider-driven token lifecycle**: The provider decides whether its cached token is still good, refreshes it, or logs in; the broker persists what it returns
|
|
36
|
+
- 🪙 **A token API for whoever wants a token and nothing else**: `getToken`, `refreshToken` and `createTokenRefresher` on the destination's own provider, or on one you give the broker, as in 3.x
|
|
23
37
|
- ⚡ **Forced refresh**: `refreshToken()` obtains a new token even when the cached one looks valid — for a caller holding a 401
|
|
24
38
|
- 🧾 **JWT or SAML cookies**: what the provider returns is saved as a token or as session cookies
|
|
25
39
|
- 🔑 **No secrets copied**: The client secret stays in the service key; the session store gets tokens only
|
|
40
|
+
- 📜 **x509 service keys**: a client that authenticates with a certificate instead of a secret (`tls_client_auth`), when you say so — `clientAuthentication: fromServiceKeyCertificate()`; the certificate and key are read from the key store, and never reach the session store, a log line or an error
|
|
26
41
|
|
|
27
42
|
## Installation
|
|
28
43
|
|
|
@@ -38,10 +53,13 @@ supported as well.
|
|
|
38
53
|
|
|
39
54
|
### Basic Usage
|
|
40
55
|
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
This is the token API with a provider of your own — the 3.x way, which keeps
|
|
57
|
+
working. The broker takes a session store, an optional service key store, and
|
|
58
|
+
a token provider implementing `IRefreshableTokenProvider` (from
|
|
43
59
|
`@mcp-abap-adt/interfaces-auth`) — or a factory that builds one per
|
|
44
|
-
destination
|
|
60
|
+
destination. Without a `provider` the token API serves what the destination
|
|
61
|
+
states, on the provider `getProvider` builds (see *Getting Tokens*); for a
|
|
62
|
+
`@mcp-abap-adt/connection` 10 connector, use `getProvider` itself.
|
|
45
63
|
|
|
46
64
|
```typescript
|
|
47
65
|
import { AuthBroker } from '@mcp-abap-adt/auth-broker';
|
|
@@ -85,18 +103,639 @@ The factory receives:
|
|
|
85
103
|
- `connConfig` — the session's connection config, with `serviceUrl` resolved
|
|
86
104
|
(from the session, else the service key) and the token stored last.
|
|
87
105
|
|
|
106
|
+
These are 3.x's reads, in 3.x's order, kept for this path: a session store
|
|
107
|
+
that still answers a client or a URL is read first. auth-stores 3's session
|
|
108
|
+
stores answer neither, so the key store's are what is found. What the broker
|
|
109
|
+
writes back is the session secret alone (see *Getting Tokens*).
|
|
110
|
+
|
|
88
111
|
A provider instance can be passed instead of a factory; it is then used as
|
|
89
112
|
given, for every destination, and the broker seeds it with nothing:
|
|
90
113
|
|
|
91
114
|
```typescript
|
|
92
115
|
const broker = new AuthBroker({
|
|
93
116
|
sessionStore: new AbapSessionStore('/path/to/sessions'),
|
|
94
|
-
provider: new AuthorizationCodeProvider({
|
|
117
|
+
provider: new AuthorizationCodeProvider({
|
|
118
|
+
uaaUrl, clientId, clientSecret,
|
|
119
|
+
authorization: browserCallbackStrategy({ browser: 'system' }),
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
> `AuthorizationCodeProvider` and the other `@mcp-abap-adt/auth-providers` 5
|
|
125
|
+
> providers implement `IRefreshableTokenProvider` (from `@mcp-abap-adt/interfaces-auth`
|
|
126
|
+
> 3). Since auth-providers 5 the interactive ones take their `authorization`
|
|
127
|
+
> strategy explicitly — none is built for you.
|
|
128
|
+
|
|
129
|
+
### A Provider for a Connector: `getProvider`
|
|
130
|
+
|
|
131
|
+
`getProvider(destination)` returns the `IAuthProvider` (from
|
|
132
|
+
`@mcp-abap-adt/interfaces-auth` 3) that a `@mcp-abap-adt/connection` 10
|
|
133
|
+
connector takes as it is. The destination states which provider it gets; the
|
|
134
|
+
broker reads two stores, each for one role:
|
|
135
|
+
|
|
136
|
+
- **the means** — `authType`, `grantType`, basic's user and password, the SNC,
|
|
137
|
+
OIDC and SAML fields, `serviceUrl`, the client — from the **service key store**
|
|
138
|
+
(`getConnectionConfig`), and only from there;
|
|
139
|
+
- **the secret** — the token or session cookies, `expiresAt`, the refresh
|
|
140
|
+
token — from the **session store** (`loadSession`), and only from there.
|
|
141
|
+
|
|
142
|
+
Means a session store happens to answer are not read, and neither is a token
|
|
143
|
+
or cookies a key store happens to answer. Nothing is inferred: the
|
|
144
|
+
destination's `authType` (and `grantType`, for `jwt` and `saml`) decides the
|
|
145
|
+
provider, never which other fields are present.
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
import { AuthBroker } from '@mcp-abap-adt/auth-broker';
|
|
149
|
+
|
|
150
|
+
const broker = new AuthBroker({
|
|
151
|
+
serviceKeyStore: myKeyStore, // the means — required by getProvider
|
|
152
|
+
sessionStore: mySessionStore, // the secret
|
|
153
|
+
// no `provider`: that option is the token API's source, not getProvider's
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const provider = await broker.getProvider('DEV'); // an IAuthProvider
|
|
157
|
+
// const connection = createAbapConnection({ url, provider }, logger) …
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
| `authType` / `grantType` | Provider (auth-providers 5.3) | Read from the key store | Read from the session store |
|
|
161
|
+
|---|---|---|---|
|
|
162
|
+
| `basic` (no grant read) | `new BasicAuthProvider(username, password)` | `username`, `password` | nothing |
|
|
163
|
+
| `snc` (no grant read) | `SncLogonProvider.forSecureLoginClient({ partnerName, qop, sncLib, myName, logger })` | `sncPartnerName` (required); `sncQop`, `sncLib`, `sncMyName` when set | nothing |
|
|
164
|
+
| `jwt` / `authorization_code` | `AuthorizationCodeProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret`; `serviceUrl`, `sapClient` for the binding | the seed: `authorizationToken`, `refreshToken`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
|
|
165
|
+
| `jwt` / `client_credentials` | `ClientCredentialsProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret`; `serviceUrl`, `sapClient` for the binding | nothing (the row takes the client alone) |
|
|
166
|
+
| `jwt` / `passcode` | `UaaPasscodeProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret` (`''` = a public client); `serviceUrl`, `sapClient` for the binding | the seed: `authorizationToken`, `refreshToken`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
|
|
167
|
+
| `jwt` / `oidc_authorization_code` | `OidcBrowserProvider` (PKCE) | `uaaClientId`, `uaaClientSecret` (`''` = a public client); `oidcIssuerUrl`, or `oidcAuthorizationEndpoint` + `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` for the binding | the seed — used only when `issuedFor` and `issuedBy` match |
|
|
168
|
+
| `jwt` / `device_code` | `OidcDeviceFlowProvider` | the client as above; `oidcIssuerUrl`, or `oidcDeviceAuthorizationEndpoint` + `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` | the seed, as above |
|
|
169
|
+
| `jwt` / `password` | `OidcPasswordProvider` | the client as above; `username`, `password`; `oidcIssuerUrl`, or `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` | the seed, as above |
|
|
170
|
+
| `jwt` / `token_exchange` | `OidcTokenExchangeProvider` (RFC 8693) | the client as above; `oidcSubjectToken`, `oidcSubjectTokenType`; `oidcAudience`, `oidcActorToken`, `oidcActorTokenType` when set; `oidcScopes`, joined by one space into its `scope`; `oidcIssuerUrl`, or `oidcTokenEndpoint`; `serviceUrl`, `sapClient` | the seed, as above (it has no refresh grant: a renewal exchanges again) |
|
|
171
|
+
| `saml` / `saml2_pure` | `Saml2PureProvider` | `samlIdpSsoUrl`, `samlSpEntityId`, `samlIdpEntityId` (the expected issuer), `samlIdpCertificates`; `samlAcsUrl`, `samlRelayState`, `samlIdpInitiated`, `samlClockSkewMs` when set; `serviceUrl`, `sapClient` for the binding | the seed: `sessionCookies`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
|
|
172
|
+
| `saml` / `saml2_bearer` | `Saml2BearerProvider` (RFC 7522) | the same SAML fields; `samlTokenUrl` when set; the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret` (`''` = a public client); `serviceUrl`, `sapClient` | the seed: `authorizationToken`, `refreshToken`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
|
|
173
|
+
| `jwt` / `none` | `TokenAuthProvider.fixed(authorizationToken)` | `authType`, `grantType`, `serviceUrl` (+ `sapClient`); an issuer, when stated: `oidcIssuerUrl`, or the client's `uaaUrl` + `uaaClientId` | `authorizationToken` (required), `issuedFor` (required to match); `issuedBy` when the means state an issuer |
|
|
174
|
+
| `saml` / `none` | `new SamlAuthProvider(sessionCookies)` | `authType`, `grantType`, `serviceUrl` (+ `sapClient`); `samlAcsUrl` when stated | `sessionCookies` (required), `issuedFor` (required to match); `issuedBy` when `samlAcsUrl` is stated |
|
|
175
|
+
|
|
176
|
+
**No provider reads `serviceUrl`.** The URL of the system is where the
|
|
177
|
+
connector connects, not authorization data: no token provider reads it — a
|
|
178
|
+
UAA grant needs the client and `uaaUrl` — so `getProvider` neither requires it
|
|
179
|
+
nor passes it to a provider. Give the connector its URL from your key store
|
|
180
|
+
(`getConnectionConfig`), as the key store answers it. The broker reads it,
|
|
181
|
+
with `sapClient`, for one thing only: to bind a stored secret to the resource
|
|
182
|
+
it was obtained for (see *A Secret Is Bound to Its Resource and Issuer*). A
|
|
183
|
+
token destination without it still gets its provider, but no stored secret
|
|
184
|
+
is reused for it.
|
|
185
|
+
|
|
186
|
+
`none` is how a handed-over credential is stated: the key store says
|
|
187
|
+
`grantType: 'none'`, and the token or cookies live in the session. The SNC
|
|
188
|
+
row takes the contract's own defaults when a field is absent — the library is
|
|
189
|
+
discovered (`SNC_LIB_64`, `SNC_LIB`, the Secure Login Client's install path)
|
|
190
|
+
when `sncLib` is, the user's SNC name comes from the credential when
|
|
191
|
+
`sncMyName` is, and `qop` is the provider's `'9'` when `sncQop` is.
|
|
192
|
+
|
|
193
|
+
The allowed pairs are `jwt` with `authorization_code`, `client_credentials`,
|
|
194
|
+
`passcode`, `oidc_authorization_code`, `device_code`, `password`,
|
|
195
|
+
`token_exchange` or `none`, and `saml` with `saml2_pure`, `saml2_bearer` or
|
|
196
|
+
`none`. A pair outside them is a `DestinationConfigError` naming `grantType`.
|
|
197
|
+
|
|
198
|
+
**The UAA grants.** The client comes from the key store's
|
|
199
|
+
`getAuthorizationConfig` — never from the session store. A session is the
|
|
200
|
+
seed: the stored token is presented while it is valid (a JWT's own `exp`
|
|
201
|
+
decides; the stored `expiresAt` serves a token that carries none), and the
|
|
202
|
+
stored refresh token renews it. Without a session the provider is built
|
|
203
|
+
unseeded and obtains its first token at `prepare()`. `uaaClientSecret: ''` is
|
|
204
|
+
a public client: `passcode` takes it as no secret; `AuthorizationCodeProvider`
|
|
205
|
+
and `ClientCredentialsProvider` require a secret, so for them `''` is missing.
|
|
206
|
+
With a `clientAuthentication` strategy no secret is read or required: the
|
|
207
|
+
client authenticates with the strategy's answer — a certificate, say (see *How
|
|
208
|
+
the Client Authenticates*).
|
|
209
|
+
|
|
210
|
+
**The `authorization` option** is the interactive half of
|
|
211
|
+
`authorization_code` and `passcode`: a function of the destination and the
|
|
212
|
+
grant, returning the `IAuthorizationStrategy<string>` the provider logs in
|
|
213
|
+
with. The broker calls it once, when it builds that destination's provider,
|
|
214
|
+
and never disposes what it returns — whoever constructs, disposes. For
|
|
215
|
+
`passcode` the strategy is handed `<uaaUrl>/passcode` as the URL to send the
|
|
216
|
+
user to, and returns the code. There is no default: a destination whose grant
|
|
217
|
+
needs it, without it, is a `DestinationConfigError` naming `authorization`.
|
|
218
|
+
A headless process passes one that refuses (see *Headless Processes*).
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
import { browserCallbackStrategy, manualPasscodeStrategy } from '@mcp-abap-adt/auth-providers';
|
|
222
|
+
|
|
223
|
+
const broker = new AuthBroker({
|
|
224
|
+
serviceKeyStore: myKeyStore,
|
|
225
|
+
sessionStore: mySessionStore,
|
|
226
|
+
authorization: (destination, grant) =>
|
|
227
|
+
grant === 'passcode'
|
|
228
|
+
? manualPasscodeStrategy({ timeoutMs: 300_000 })
|
|
229
|
+
: browserCallbackStrategy({ timeoutMs: 120_000 }),
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**The OIDC grants.** The client is the key store's `getAuthorizationConfig`
|
|
234
|
+
again: `uaaClientId`, and `uaaClientSecret` — `''` is a public client, sent
|
|
235
|
+
with no secret. (A key store answers a client when it holds one: from
|
|
236
|
+
auth-stores 3.2.0, `EnvDestinationStore` answers one whenever `uaaClientId`
|
|
237
|
+
is stated, a missing `uaaUrl` or secret as `''` — so an OIDC destination
|
|
238
|
+
states its issuer in `oidcIssuerUrl` alone, and a public client no secret.)
|
|
239
|
+
The endpoints come from `oidcIssuerUrl`, which the provider discovers them
|
|
240
|
+
from, or — without it — from every explicit endpoint the row reads
|
|
241
|
+
(`oidcAuthorizationEndpoint`, `oidcDeviceAuthorizationEndpoint`,
|
|
242
|
+
`oidcTokenEndpoint`); without either the error names `oidcIssuerUrl` and the
|
|
243
|
+
endpoints missing. `oidcScopes` is passed as given (`token_exchange` takes
|
|
244
|
+
one `scope` string: the scopes joined by a space). The subject and actor
|
|
245
|
+
tokens of `token_exchange` and the user and password of `password` are
|
|
246
|
+
means: sent to the token endpoint, never written to the session.
|
|
247
|
+
|
|
248
|
+
**The SAML grants.** Each provider validates the assertion before anything
|
|
249
|
+
uses it, with a validator the broker composes from the destination's trust
|
|
250
|
+
and your replay store: `createSignedResponseValidator` for `saml2_pure` (the
|
|
251
|
+
Response must be signed — the cookies' system receives it whole) and
|
|
252
|
+
`createSignedAssertionValidator` for `saml2_bearer` (the Assertion must be
|
|
253
|
+
signed — the token endpoint receives it alone), from `samlIdpCertificates`
|
|
254
|
+
(PEM or base64 DER; several during a rotation), `samlClockSkewMs` and
|
|
255
|
+
`assertionReplayStore(destination)`; `samlIdpEntityId` is the issuer every
|
|
256
|
+
assertion must name. A certificate the validator cannot read, or a
|
|
257
|
+
`samlClockSkewMs` that is not a whole, non-negative number of milliseconds,
|
|
258
|
+
is a `DestinationConfigError` naming the field. `samlIdpInitiated: true`
|
|
259
|
+
declares an IdP-initiated login: no AuthnRequest, and an assertion carrying
|
|
260
|
+
no `InResponseTo` — your strategy then hands over the SAMLResponse without
|
|
261
|
+
asking for an authorization URL (a strategy that asks gets Oops from
|
|
262
|
+
`prepare()`). `saml2_pure` is seeded with the stored cookies and their
|
|
263
|
+
`expiresAt` — cookies carry no expiry of their own, so the provider keeps them
|
|
264
|
+
until the assertion's earliest `NotOnOrAfter` (less the provider's one-minute
|
|
265
|
+
margin: an identity provider whose assertions live a minute or less makes
|
|
266
|
+
every request a login); SAML has no refresh token, so a renewal is a new
|
|
267
|
+
login through your strategy. `saml2_bearer` posts the Assertion to
|
|
268
|
+
`samlTokenUrl`, else `<uaaUrl>/oauth/token`, with the client, and renews by
|
|
269
|
+
its refresh token.
|
|
270
|
+
|
|
271
|
+
**The collaborator options** — each a function of the destination, called
|
|
272
|
+
once when that destination's provider is built, never disposed by the
|
|
273
|
+
broker, and required only by the rows that use it; a row whose option is
|
|
274
|
+
missing is a `DestinationConfigError` naming it:
|
|
275
|
+
|
|
276
|
+
| Option | Rows | What it returns |
|
|
277
|
+
|---|---|---|
|
|
278
|
+
| `authorization(destination, grant)` | `authorization_code`, `passcode`, `saml2_pure`, `saml2_bearer` (the grant is passed) | the `IAuthorizationStrategy<string>` that conducts the login — for the SAML grants, the one that returns the SAMLResponse (`samlCallbackStrategy`, `manualSamlResponseStrategy`, …) |
|
|
279
|
+
| `oidcAuthorization(destination)` | `oidc_authorization_code` | an `IAuthorizationStrategy<OidcCallbackResult>` (`oidcCallbackStrategy`, or `asOidcResult(…)` over a string strategy) |
|
|
280
|
+
| `deviceCodePresenter(destination)` | `device_code` | an `IDeviceCodePresenter` that shows the user the verification URL and code (`consoleDeviceCodePresenter(logger)`, or your UI) |
|
|
281
|
+
| `samlCookies(destination)` | `saml2_pure` | `(samlResponse) => Promise<string>`: posts the validated SAMLResponse to the system's ACS and returns the session cookies it sets |
|
|
282
|
+
| `assertionReplayStore(destination)` | `saml2_pure`, `saml2_bearer` | the `IAssertionReplayStore` the validator records each assertion in, refusing one presented twice — `defaultReplayStore` (process-wide, in memory) or a shared one of yours |
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
import {
|
|
286
|
+
consoleDeviceCodePresenter,
|
|
287
|
+
defaultReplayStore,
|
|
288
|
+
oidcCallbackStrategy,
|
|
289
|
+
samlCallbackStrategy,
|
|
290
|
+
} from '@mcp-abap-adt/auth-providers';
|
|
291
|
+
|
|
292
|
+
const broker = new AuthBroker(
|
|
293
|
+
{
|
|
294
|
+
serviceKeyStore: myKeyStore,
|
|
295
|
+
sessionStore: mySessionStore,
|
|
296
|
+
authorization: (destination, grant) =>
|
|
297
|
+
grant === 'saml2_pure' || grant === 'saml2_bearer'
|
|
298
|
+
? samlCallbackStrategy({ timeoutMs: 120_000 })
|
|
299
|
+
: browserCallbackStrategy({ timeoutMs: 120_000 }),
|
|
300
|
+
oidcAuthorization: () => oidcCallbackStrategy({ timeoutMs: 120_000 }),
|
|
301
|
+
deviceCodePresenter: () => consoleDeviceCodePresenter(logger),
|
|
302
|
+
samlCookies: (destination) => (samlResponse) =>
|
|
303
|
+
postToAcs(destination, samlResponse), // yours: the system's ACS answers Set-Cookie
|
|
304
|
+
assertionReplayStore: () => defaultReplayStore,
|
|
305
|
+
},
|
|
306
|
+
logger,
|
|
307
|
+
);
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### A Secret Is Bound to Its Resource and Issuer
|
|
311
|
+
|
|
312
|
+
A token's audience, cookies' host and path, a system's client: presenting a
|
|
313
|
+
secret to a resource it was not obtained for is a leak, and a secret from
|
|
314
|
+
another authorization server or client must not stand in for this
|
|
315
|
+
destination's. So the session store keeps, beside the secret, two strings
|
|
316
|
+
(`IConnectionConfig`, `@mcp-abap-adt/interfaces-auth-broker` 1.1.0):
|
|
317
|
+
|
|
318
|
+
- **`issuedFor`** — the resource: `serviceUrl` with the SAP client, e.g.
|
|
319
|
+
`https://my-abap.example.com:443/sap/bc/adt?sap-client=100`;
|
|
320
|
+
- **`issuedBy`** — who issued it, to which client: `uaaUrl` with
|
|
321
|
+
`client_id=<uaaClientId>` for the UAA grants and `saml2_bearer`, e.g.
|
|
322
|
+
`https://sub.authentication.us10.hana.ondemand.com:443?client_id=sb-abap-trial`;
|
|
323
|
+
`oidcIssuerUrl` — else `uaaUrl` — with `client_id=<uaaClientId>` for the
|
|
324
|
+
OIDC grants; for `saml2_pure`'s cookies, the ACS of the system that set
|
|
325
|
+
them: `samlAcsUrl`, origin and path.
|
|
326
|
+
|
|
327
|
+
The broker computes both from the destination's means and compares them,
|
|
328
|
+
canonicalised on **both** sides, with what the session holds:
|
|
329
|
+
|
|
330
|
+
- **The canonical form:** scheme and host lower-cased; the port explicit
|
|
331
|
+
(`443` for `https`, `80` for `http`); the path without a trailing `/` (the
|
|
332
|
+
root path is empty); one query parameter at most — `sap-client` (the means'
|
|
333
|
+
`sapClient` wins over a `sap-client` already in `serviceUrl`) or
|
|
334
|
+
`client_id` — parsed and re-encoded, so a value percent-encoded on one side
|
|
335
|
+
and plain on the other compares equal; no user info, no other parameter,
|
|
336
|
+
no fragment. A URL that does not parse binds nothing. A store keeps the
|
|
337
|
+
strings as given and need not canonicalise them.
|
|
338
|
+
- **The grants that obtain a secret** (UAA, OIDC, SAML)**:** a stored secret seeds the provider only when **both**
|
|
339
|
+
stored values equal the computed ones. Otherwise — either different, or
|
|
340
|
+
absent on either side (no `serviceUrl` in the means, an OIDC destination
|
|
341
|
+
stating neither `oidcIssuerUrl` nor `uaaUrl`, a `saml2_pure` one without
|
|
342
|
+
`samlAcsUrl`, a session written without them) — the secret is not used,
|
|
343
|
+
**refresh token included**: the
|
|
344
|
+
provider is built as with no session and logs in afresh by its grant, and
|
|
345
|
+
the log says only `<destination>: secret bound to another resource,
|
|
346
|
+
discarded` — never a URI, never a token. The new secret is written with
|
|
347
|
+
both fields.
|
|
348
|
+
- **The `none` rows** present a credential the broker cannot obtain again, so
|
|
349
|
+
a mismatch is refused, not discarded: a `DestinationConfigError` naming
|
|
350
|
+
`issuedFor` when the stored resource differs or is absent (or the means
|
|
351
|
+
state no `serviceUrl`), and naming `issuedBy` when the means state an issuer
|
|
352
|
+
(`oidcIssuerUrl` or the client for `jwt`, `samlAcsUrl` for `saml` — then
|
|
353
|
+
canonicalised as origin and path) and the stored one differs or is absent.
|
|
354
|
+
Means that state no issuer leave `issuedBy` uncompared.
|
|
355
|
+
- **The binding does not check the token.** Its audience stays the
|
|
356
|
+
resource's to enforce; the broker compares two strings and parses no token.
|
|
357
|
+
- **`bindingOf(means, client?)` — for a consumer that hands over a
|
|
358
|
+
credential.** A `none` destination presents a token or cookies the broker
|
|
359
|
+
cannot obtain; whoever writes them to the session store must write the
|
|
360
|
+
binding beside them. `bindingOf` answers exactly what the broker computes for
|
|
361
|
+
those means — the one function `getProvider` checks against and `persist`
|
|
362
|
+
writes (`issuedFor` from `serviceUrl` + `sapClient`, `issuedBy` from the
|
|
363
|
+
issuer, client or ACS the grant uses) — so a consumer never canonicalises a
|
|
364
|
+
URI itself:
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
const means = await keyStore.getConnectionConfig('DEV'); // saml / none
|
|
368
|
+
await sessionStore.saveSession('DEV', {
|
|
369
|
+
sessionCookies: cookies,
|
|
370
|
+
...bindingOf(means ?? {}), // { issuedFor: 'https://dev.example.com:443?sap-client=100' }
|
|
371
|
+
});
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
A destination that states no `jwt` / `saml` type or no grant binds nothing
|
|
375
|
+
(`{}`). `mcp-sso … --cookie` writes its cookies this way.
|
|
376
|
+
|
|
377
|
+
**What you meet:**
|
|
378
|
+
|
|
379
|
+
1. **A custom `ISessionStore`** (a database, a message log, a secret store)
|
|
380
|
+
must persist `issuedFor` and `issuedBy` beside the secret, and answer
|
|
381
|
+
them from `loadSession`. One that drops them still type-checks, but the
|
|
382
|
+
broker then never reuses its sessions: every process start is a fresh
|
|
383
|
+
login — a browser each time for an interactive grant.
|
|
384
|
+
2. **A headless process whose strategy refuses logins** gets Oops ("login
|
|
385
|
+
required") from `prepare()` / `rejected()` on a mismatch, instead of
|
|
386
|
+
presenting a foreign secret; log in again with the CLI.
|
|
387
|
+
3. **Changing a destination's URL, SAP client, UAA or client** costs one
|
|
388
|
+
fresh login — intended.
|
|
389
|
+
4. **Session files written before auth-stores 3.1.0** keep working: its
|
|
390
|
+
session stores answer `issuedFor` from the file's `SAP_URL` (+
|
|
391
|
+
`SAP_CLIENT`) and `issuedBy` from `SAP_UAA_URL` + `SAP_UAA_CLIENT_ID`,
|
|
392
|
+
which the 3.x broker and CLI wrote with the token — so a stored token whose
|
|
393
|
+
URL and client are the destination's is reused. XSUAA sessions written with
|
|
394
|
+
an empty URL have no `issuedFor`, and log in once.
|
|
395
|
+
|
|
396
|
+
**One provider per destination** for the broker's life, shared with the token
|
|
397
|
+
API when the broker has no `provider` option (see *Getting Tokens*):
|
|
398
|
+
concurrent first calls share one build, and a build that threw is tried again
|
|
399
|
+
on the next call. A destination rewritten from outside is picked up by a new
|
|
400
|
+
broker.
|
|
401
|
+
|
|
402
|
+
### How the Client Authenticates: `clientAuthentication`
|
|
403
|
+
|
|
404
|
+
Every grant whose client authenticates to the authorization server — the UAA
|
|
405
|
+
grants (`authorization_code`, `client_credentials`, `passcode`), the OIDC
|
|
406
|
+
grants (`oidc_authorization_code`, `device_code`, `password`,
|
|
407
|
+
`token_exchange`) and `saml2_bearer` — sends the client's secret, as in 4.0.0,
|
|
408
|
+
unless you tell the broker otherwise. An XSUAA service key created with
|
|
409
|
+
`{"credential-type": "x509"}` holds no secret: it holds a client certificate,
|
|
410
|
+
its private key and the mTLS host the certificate is presented to (`certurl`).
|
|
411
|
+
How the client authenticates is your choice, stated as a strategy — the broker
|
|
412
|
+
never infers it from a key's shape and has no default:
|
|
413
|
+
|
|
414
|
+
```typescript
|
|
415
|
+
import {
|
|
416
|
+
AuthBroker,
|
|
417
|
+
fromServiceKeyCertificate,
|
|
418
|
+
} from '@mcp-abap-adt/auth-broker';
|
|
419
|
+
import {
|
|
420
|
+
XsuaaServiceKeyStore,
|
|
421
|
+
XsuaaSessionStore,
|
|
422
|
+
} from '@mcp-abap-adt/auth-stores';
|
|
423
|
+
|
|
424
|
+
const broker = new AuthBroker({
|
|
425
|
+
serviceKeyStore: new XsuaaServiceKeyStore('/path/to/keys', {
|
|
426
|
+
grantType: 'client_credentials',
|
|
427
|
+
}),
|
|
428
|
+
sessionStore: new XsuaaSessionStore('/path/to/sessions'),
|
|
429
|
+
// The key's certificate, presented at <certurl>/oauth/token.
|
|
430
|
+
clientAuthentication: fromServiceKeyCertificate(),
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const provider = await broker.getProvider('mcp'); // its client: the certificate
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
**The strategy** is `(context) => Promise<IClientAuthentication>`
|
|
437
|
+
(`ClientAuthenticationStrategy`). The broker calls it once when it builds a
|
|
438
|
+
destination's provider, with a `ClientAuthenticationContext`:
|
|
439
|
+
|
|
440
|
+
- `destination` and `grant` (a `ClientAuthenticationGrant`, one of the eight
|
|
441
|
+
above);
|
|
442
|
+
- `client` — the secret client the key store's `getAuthorizationConfig`
|
|
443
|
+
answered, as `uaaUrl`, `uaaClientId` and `uaaClientSecret` only — never a
|
|
444
|
+
refresh token or any other field the store answered with it — or `null`
|
|
445
|
+
(an x509 key answers `null` there, never a client with an empty secret);
|
|
446
|
+
- `readCertificate()` — the key store's certificate client
|
|
447
|
+
(`IClientCertificate`: `uaaUrl`, `clientId`, `certificate`, `key`,
|
|
448
|
+
`certUrl`), read **only when called**, at most once per build; `null` when
|
|
449
|
+
the store holds none or implements no `getClientCertificate`.
|
|
450
|
+
|
|
451
|
+
Its answer goes to the provider as `clientAuthentication`, and **no client
|
|
452
|
+
secret goes with it** (auth-providers 5.3 refuses both). A given strategy
|
|
453
|
+
always answers or throws: there is no "nothing" answer, so an explicit choice
|
|
454
|
+
never falls back to the secret. `saml2_pure`, the `none` rows, `basic` and
|
|
455
|
+
`snc` authenticate no client and never call it.
|
|
456
|
+
|
|
457
|
+
**The two shipped factories** each fail closed:
|
|
458
|
+
|
|
459
|
+
| Factory | Answers | Throws (→ the refusal below) |
|
|
460
|
+
|---|---|---|
|
|
461
|
+
| `fromServiceKeyCertificate()` | auth-providers' `tlsClientCertificate` with the certificate and key `readCertificate()` answers, against `<certUrl>/oauth/token` (a trailing `/` of `certUrl` dropped). The material is checked before the factory answers, so a malformed, incomplete or expired certificate is refused when the provider is built, not at its first token request | the store answers no certificate client: "the destination has no client certificate" |
|
|
462
|
+
| `fromServiceKeySecret({ encoding })` | auth-providers' `clientSecretBasic` with the secret client's `uaaClientSecret`, in an `Authorization: Basic` header. `encoding` is required: `'raw'` for XSUAA (measured — it does not form-decode), `'form'` for UAA and Keycloak (RFC 6749 §2.3.1); anything else is a `TypeError` when the factory is made | no secret client, or an empty secret: "the destination has no client secret" |
|
|
463
|
+
|
|
464
|
+
**Composing them is yours.** A fallback, and its order, is your statement,
|
|
465
|
+
never the broker's. Branch on what the key holds (`readCertificate()` is
|
|
466
|
+
memoised, so the factory reads the same answer); do not `catch` a factory's
|
|
467
|
+
refusal and fall back — that would also swallow an expired, incomplete or
|
|
468
|
+
unreadable certificate and send the secret instead:
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
import {
|
|
472
|
+
type ClientAuthenticationStrategy,
|
|
473
|
+
fromServiceKeyCertificate,
|
|
474
|
+
fromServiceKeySecret,
|
|
475
|
+
} from '@mcp-abap-adt/auth-broker';
|
|
476
|
+
|
|
477
|
+
// The certificate when the key holds one, else the secret in a Basic header.
|
|
478
|
+
// Decided by what the key holds, never by a failure: an expired, incomplete or
|
|
479
|
+
// unreadable certificate is refused, not replaced by the secret.
|
|
480
|
+
const certificateElseSecret: ClientAuthenticationStrategy = async (context) =>
|
|
481
|
+
(await context.readCertificate())
|
|
482
|
+
? fromServiceKeyCertificate()(context)
|
|
483
|
+
: fromServiceKeySecret({ encoding: 'raw' })(context);
|
|
484
|
+
|
|
485
|
+
// The certificate for client_credentials only; every other grant its secret.
|
|
486
|
+
const byGrant: ClientAuthenticationStrategy = (context) =>
|
|
487
|
+
context.grant === 'client_credentials'
|
|
488
|
+
? fromServiceKeyCertificate()(context)
|
|
489
|
+
: fromServiceKeySecret({ encoding: 'raw' })(context);
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
**Where the certificate comes from** — `IServiceKeyStore.getClientCertificate?`
|
|
493
|
+
(`@mcp-abap-adt/interfaces-auth-broker` 1.2.0, optional), which auth-stores
|
|
494
|
+
3.3.0 implements:
|
|
495
|
+
|
|
496
|
+
- **`XsuaaServiceKeyStore`** — a key (bare, or wrapped in `credentials`)
|
|
497
|
+
carrying `url`, `clientid`, `certificate`, `key` and `certurl` and no
|
|
498
|
+
`clientsecret` is an x509 key: `getAuthorizationConfig` answers `null`,
|
|
499
|
+
`getClientCertificate` the certificate client. A key carrying both a secret
|
|
500
|
+
and a complete certificate offers both — the secret client as before, the
|
|
501
|
+
certificate client beside it — and your strategy picks; the store does not
|
|
502
|
+
read `credential-type` to choose. A key carrying only part of a
|
|
503
|
+
certificate client is refused by `getClientCertificate` (`incomplete`),
|
|
504
|
+
naming the missing fields, never a value. `AbapServiceKeyStore` holds no
|
|
505
|
+
certificate client.
|
|
506
|
+
- **`EnvDestinationStore`** — three means variables, each a path or a URL,
|
|
507
|
+
never PEM: `SAP_UAA_CLIENT_CERT_PATH`, `SAP_UAA_CLIENT_KEY_PATH`,
|
|
508
|
+
`SAP_UAA_CERT_URL` (`XSUAA_UAA_CLIENT_CERT_PATH`, … with
|
|
509
|
+
`XSUAA_DESTINATION_VARS`), beside `SAP_UAA_URL` / `SAP_UAA_CLIENT_ID` and
|
|
510
|
+
**without** `SAP_UAA_CLIENT_SECRET`. All three and no secret is a
|
|
511
|
+
certificate client (the two files read when `getClientCertificate` is
|
|
512
|
+
called); none of them is 4.0.0's answer; some but not all, or any beside the
|
|
513
|
+
secret, is the store's `ClientCertificateError` from both methods, naming
|
|
514
|
+
the variables. A file that states neither a client id, a secret nor any of
|
|
515
|
+
the three asks its `fallback` — so an `EnvDestinationStore` over an `XsuaaServiceKeyStore`
|
|
516
|
+
answers the x509 key's certificate client. See auth-stores' README for the
|
|
517
|
+
details.
|
|
518
|
+
|
|
519
|
+
```env
|
|
520
|
+
# A certificate destination — EnvDestinationStore (ABAP_DESTINATION_VARS)
|
|
521
|
+
SAP_AUTH_TYPE=jwt
|
|
522
|
+
SAP_GRANT_TYPE=client_credentials
|
|
523
|
+
SAP_URL=https://your-system.abap.us10.hana.ondemand.com
|
|
524
|
+
SAP_UAA_URL=https://your-account.authentication.us10.hana.ondemand.com
|
|
525
|
+
SAP_UAA_CLIENT_ID=sb-your-app!t12345
|
|
526
|
+
SAP_UAA_CLIENT_CERT_PATH=/home/you/keys/client.crt
|
|
527
|
+
SAP_UAA_CLIENT_KEY_PATH=/home/you/keys/client.key
|
|
528
|
+
SAP_UAA_CERT_URL=https://your-account.authentication.cert.us10.hana.ondemand.com
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
**Without a strategy nothing changes:** the client secret, exactly as 4.0.0,
|
|
532
|
+
and nothing certificate-related is read — no `getClientCertificate`, no file.
|
|
533
|
+
A client row whose key store answers no client, or one without a client id,
|
|
534
|
+
now says, after 4.0.0's words, `a certificate client needs a clientAuthentication strategy` (see
|
|
535
|
+
*Migrating from 4.0.0*).
|
|
536
|
+
|
|
537
|
+
**With a strategy, what the row requires** is its client's identity —
|
|
538
|
+
`uaaUrl` and `uaaClientId` (the OIDC rows: `uaaClientId`) — taken from the
|
|
539
|
+
secret client when the key store has one, else from the certificate client
|
|
540
|
+
(`uaaUrl`, `clientId`), read through the same memoised `readCertificate`; the
|
|
541
|
+
secret is no longer required.
|
|
542
|
+
|
|
543
|
+
**The binding on the strategy path.** A secret is bound to the client's
|
|
544
|
+
identity (`issuedBy` = `uaaUrl` + `client_id`), never to how the client
|
|
545
|
+
authenticates and never to PEM:
|
|
546
|
+
|
|
547
|
+
- a certificate destination's tokens are stored with `issuedBy` from the
|
|
548
|
+
certificate client's `uaaUrl` and `clientId`, reused after the broker is
|
|
549
|
+
recreated, and dropped after the issuer or the client id changes;
|
|
550
|
+
- a secret key and an x509 key of the **same client id** at the same
|
|
551
|
+
`uaaUrl` (as an XSUAA instance's keys are) share one session: switching the
|
|
552
|
+
destination from one to the other keeps its token and refresh token;
|
|
553
|
+
- the resource (`issuedFor`) matches when both sides state it and it is
|
|
554
|
+
equal — **or when neither does**: means with no `serviceUrl`, or one that
|
|
555
|
+
does not parse (the CLI's XSUAA placeholder is such a URL), and a session
|
|
556
|
+
stored without `issuedFor`. A resource stated on one side only never
|
|
557
|
+
matches. So an XSUAA destination without a service URL reuses its own
|
|
558
|
+
session instead of logging in on every run; the client identity still
|
|
559
|
+
decides. (Without a strategy, 4.0.0's rule stands: an absent resource
|
|
560
|
+
matches nothing.)
|
|
561
|
+
|
|
562
|
+
**The token API with a factory of yours** gets the same strategy. For a
|
|
563
|
+
destination whose grant authenticates a client, the broker resolves the
|
|
564
|
+
strategy first and calls your factory with a **fourth argument**,
|
|
565
|
+
`TokenProviderClient` — never a certificate, a key or a secret:
|
|
566
|
+
|
|
567
|
+
| Field | What it is |
|
|
568
|
+
|---|---|
|
|
569
|
+
| `clientAuthentication` | the strategy's answer, to hand to your provider |
|
|
570
|
+
| `uaaUrl`, `clientId` | the client's identity: the secret client's when the stores hold one, else the certificate client's; absent when there is neither |
|
|
571
|
+
| `refreshToken` | the refresh token the session stored — only when the session is bound to this resource and this client identity, and only from that session read (a refresh token a store's `getAuthorizationConfig` carries is dropped on this path; `authConfig` carries the same bound one) |
|
|
572
|
+
|
|
573
|
+
```typescript
|
|
574
|
+
import {
|
|
575
|
+
AuthBroker,
|
|
576
|
+
fromServiceKeyCertificate,
|
|
577
|
+
type TokenProviderFactory,
|
|
578
|
+
} from '@mcp-abap-adt/auth-broker';
|
|
579
|
+
import { ClientCredentialsProvider } from '@mcp-abap-adt/auth-providers';
|
|
580
|
+
import {
|
|
581
|
+
XsuaaServiceKeyStore,
|
|
582
|
+
XsuaaSessionStore,
|
|
583
|
+
} from '@mcp-abap-adt/auth-stores';
|
|
584
|
+
|
|
585
|
+
const certificateClient: TokenProviderFactory = (
|
|
586
|
+
destination,
|
|
587
|
+
_authConfig, // null for an x509 key
|
|
588
|
+
_connConfig,
|
|
589
|
+
client,
|
|
590
|
+
) => {
|
|
591
|
+
if (!client?.clientAuthentication || !client.uaaUrl || !client.clientId) {
|
|
592
|
+
throw new Error(`No client for ${destination}`);
|
|
593
|
+
}
|
|
594
|
+
return new ClientCredentialsProvider({
|
|
595
|
+
uaaUrl: client.uaaUrl,
|
|
596
|
+
clientId: client.clientId,
|
|
597
|
+
clientAuthentication: client.clientAuthentication,
|
|
598
|
+
});
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const tokenBroker = new AuthBroker({
|
|
602
|
+
serviceKeyStore: new XsuaaServiceKeyStore('/path/to/keys', {
|
|
603
|
+
grantType: 'client_credentials',
|
|
604
|
+
}),
|
|
605
|
+
sessionStore: new XsuaaSessionStore('/path/to/sessions'),
|
|
606
|
+
clientAuthentication: fromServiceKeyCertificate(),
|
|
607
|
+
provider: certificateClient,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const token = await tokenBroker.getToken('mcp');
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
On this path:
|
|
614
|
+
|
|
615
|
+
- the destination must state its `authType` (`jwt` or `saml`) and
|
|
616
|
+
`grantType` — the strategy is told the grant — else a
|
|
617
|
+
`DestinationConfigError` naming `authType`, `grantType`; a `saml2_pure` or
|
|
618
|
+
`none` destination authenticates no client, and its factory gets three
|
|
619
|
+
arguments, as in 4.0.0;
|
|
620
|
+
- every stored secret your factory is seeded with passes only when the
|
|
621
|
+
session is bound to this resource and this client identity: the refresh
|
|
622
|
+
token (in the fourth argument, and in `authConfig` for a secret client), and
|
|
623
|
+
the stored token, cookies and expiry in `connConfig` — so a refresh token
|
|
624
|
+
can never reach another authorization server. A result without a refresh
|
|
625
|
+
token carries forward only a bound one. (Without a strategy, 4.0.0's
|
|
626
|
+
carry-over stands: the stored refresh token whatever its binding.)
|
|
627
|
+
- what the factory is handed is built from allowlists, never a store's
|
|
628
|
+
answer passed whole: `authConfig` is `uaaUrl`, `uaaClientId`,
|
|
629
|
+
`uaaClientSecret` and the bound refresh token; `connConfig` is
|
|
630
|
+
`serviceUrl`, `sapClient`, `language`, `authType`, `grantType`, plus — when
|
|
631
|
+
that same connection read is bound — `authorizationToken`,
|
|
632
|
+
`sessionCookies`, `expiresAt`, `issuedFor`, `issuedBy`. Anything else a
|
|
633
|
+
store answers (a password, grant data, a field outside the type) stays
|
|
634
|
+
behind; without a strategy the reads go through as in 4.0.0;
|
|
635
|
+
- a factory that throws is a `DestinationConfigError` naming `provider`,
|
|
636
|
+
`clientAuthentication`, in fixed words — what it threw is not kept;
|
|
637
|
+
- a provider **instance** given as `provider` is your own composition: the
|
|
638
|
+
token API uses it as given and calls no strategy for it.
|
|
639
|
+
|
|
640
|
+
**A strategy that fails carries nothing out.** Whatever the strategy, a store
|
|
641
|
+
it reads or the certificate check throws — and an answer that is no
|
|
642
|
+
`IClientAuthentication` — becomes a `DestinationConfigError` with
|
|
643
|
+
`missingFields: ['clientAuthentication']`, before any provider exists, in
|
|
644
|
+
fixed words chosen by the error's class only — no `cause`, nothing of the
|
|
645
|
+
thrown message:
|
|
646
|
+
|
|
647
|
+
| Words (after `Destination "<name>": `) | When |
|
|
648
|
+
|---|---|
|
|
649
|
+
| `the clientAuthentication strategy refused: the destination has no client certificate` | `fromServiceKeyCertificate()`, no certificate client |
|
|
650
|
+
| `the clientAuthentication strategy refused: the destination has no client secret` | `fromServiceKeySecret()`, no secret client or an empty secret |
|
|
651
|
+
| `the clientAuthentication strategy refused: the client certificate is incomplete` / `has expired` / `could not be used` | the certificate check refused the material |
|
|
652
|
+
| `the clientAuthentication strategy answered no client authentication` | the answer has no `authenticate` function |
|
|
653
|
+
| `the client certificate could not be read` | the store failed reading the certificate client for the row's identity |
|
|
654
|
+
| `the clientAuthentication strategy failed` | anything else — your strategy's own error, a store's error (an incomplete `.env`), an unreadable file |
|
|
655
|
+
|
|
656
|
+
A certificate, a key or a file's content never reaches a log line, a refusal,
|
|
657
|
+
a `DestinationConfigError`, a thrown message or the session store.
|
|
658
|
+
|
|
659
|
+
**The certificate is pinned for the broker's lifetime.** It is read when the
|
|
660
|
+
destination's provider is built, and that provider is cached per destination
|
|
661
|
+
for as long as the `AuthBroker` lives. An XSUAA x509 key's certificate lives
|
|
662
|
+
about seven days: a long-running process then gets `the client certificate
|
|
663
|
+
has expired` from its provider until it builds a new `AuthBroker`, and
|
|
664
|
+
replacing the key or the PEM files changes nothing before that. Rotating
|
|
665
|
+
certificates is out of scope: create a new key, then a new broker (restart).
|
|
666
|
+
|
|
667
|
+
**What is measured.** `client_credentials` with an x509 XSUAA service key,
|
|
668
|
+
against XSUAA on a BTP trial (2026-10-05; the instance's `credential-types`
|
|
669
|
+
`["binding-secret", "x509"]`, its key created with
|
|
670
|
+
`{"credential-type": "x509"}`): through the broker
|
|
671
|
+
(`fromServiceKeyCertificate()` + `XsuaaServiceKeyStore`, `getProvider` and the
|
|
672
|
+
token API), through `mcp-auth --client-auth certificate` and through
|
|
673
|
+
`generate-env-from-service-key --client-auth certificate`, each followed by a
|
|
674
|
+
fresh broker over the written destination — see *Testing*. **Not measured:**
|
|
675
|
+
`authorization_code` and `passcode` over x509 (the broker builds them, nothing
|
|
676
|
+
has run them against XSUAA), the OIDC grants and `saml2_bearer` with a
|
|
677
|
+
strategy, and ABAP environment service keys with x509 (`AbapServiceKeyStore`
|
|
678
|
+
holds no certificate client).
|
|
679
|
+
|
|
680
|
+
### Persistence and `flush()`
|
|
681
|
+
|
|
682
|
+
Every token provider `getProvider` builds writes what it obtains back to the
|
|
683
|
+
session store, through its `onTokens` hook, before it answers — whichever
|
|
684
|
+
moment triggered the renewal: `prepare()`, `authorize()` on expiry, or
|
|
685
|
+
`rejected()` after a 401. A renewal inside a connector is stored before the
|
|
686
|
+
connector resends.
|
|
687
|
+
|
|
688
|
+
- **The secret alone, with its binding, in one write**:
|
|
689
|
+
`saveSession(destination, { authorizationToken, expiresAt, refreshToken,
|
|
690
|
+
issuedFor, issuedBy })` — or, for `saml2_pure`'s cookies (a result of
|
|
691
|
+
`tokenType: 'saml'`), `{ sessionCookies, expiresAt, issuedFor, issuedBy }`,
|
|
692
|
+
with no refresh token: SAML has none. `saml2_bearer`'s result is a token
|
|
693
|
+
and is written as one — `issuedFor` / `issuedBy` as computed when the
|
|
694
|
+
provider was built, each left out when the means lack its source (so the
|
|
695
|
+
store clears it). No means is ever written — not `serviceUrl`, not
|
|
696
|
+
`authType`, not the client: the client secret lives in the key store, and
|
|
697
|
+
the broker never writes the key store (its contract has no write method).
|
|
698
|
+
`expiresAt` is the result's, else the `expires_in` it reported counted from
|
|
699
|
+
when it arrived. A result without a refresh token keeps the one the session
|
|
700
|
+
holds — when that one is bound where the new secret is; one bound to
|
|
701
|
+
another resource or issuer is not carried into it.
|
|
702
|
+
- **`basic` and `snc` are never written**: they obtain no session secret, and
|
|
703
|
+
`none` obtains nothing either. A destination the key store states as
|
|
704
|
+
`basic` or `snc` at write time is not written.
|
|
705
|
+
- **The token API writes through the same path** (see *Getting Tokens*):
|
|
706
|
+
with a `provider` of yours, every answer it gets is written as above — the
|
|
707
|
+
stored refresh token carried forward whatever its binding, as your provider
|
|
708
|
+
was seeded with it.
|
|
709
|
+
- **A failed write does not fail the authentication.** The connector goes on
|
|
710
|
+
with the token it holds; the broker keeps the result pending for that
|
|
711
|
+
destination and retries on its own — one second, doubling, capped at one
|
|
712
|
+
minute — on a timer that never keeps the process alive. A newer result
|
|
713
|
+
replaces the pending one; writes for one destination never overlap. Each
|
|
714
|
+
failure is logged by its class name, never its message. The token API's
|
|
715
|
+
caller still gets the failure: `getToken()` / `refreshToken()` throw the
|
|
716
|
+
store's error for the token they received, while the retry goes on.
|
|
717
|
+
- **`flush()`** gives every pending write one more attempt, resolves when all
|
|
718
|
+
have landed, and rejects (an `AggregateError` naming the destinations; each
|
|
719
|
+
of its `errors` is `"<destination>": <error class>` — never the store's own
|
|
720
|
+
message, which may quote what was being written) when the store still
|
|
721
|
+
refuses — the broker keeps retrying.
|
|
722
|
+
Call it on shutdown — on `SIGTERM`, before a stdio transport closes — to
|
|
723
|
+
know whether every token is stored.
|
|
724
|
+
|
|
725
|
+
```typescript
|
|
726
|
+
process.on('SIGTERM', async () => {
|
|
727
|
+
try {
|
|
728
|
+
await broker.flush();
|
|
729
|
+
} catch (error) {
|
|
730
|
+
logger.error(`Tokens not stored: ${(error as Error).message}`);
|
|
731
|
+
}
|
|
732
|
+
process.exit(0);
|
|
95
733
|
});
|
|
96
734
|
```
|
|
97
735
|
|
|
98
|
-
|
|
99
|
-
|
|
736
|
+
**A destination that lacks what its type needs** is a `DestinationConfigError`
|
|
737
|
+
(see *Error Handling*), thrown by `getProvider` before any provider exists —
|
|
738
|
+
never a provider built only to refuse in `prepare()`.
|
|
100
739
|
|
|
101
740
|
### Headless Processes (No Browser)
|
|
102
741
|
|
|
@@ -129,6 +768,34 @@ try {
|
|
|
129
768
|
A cached token that is still valid, or a refresh token the UAA accepts, never
|
|
130
769
|
reaches the strategy.
|
|
131
770
|
|
|
771
|
+
With `getProvider` the same holds for every collaborator option: give the
|
|
772
|
+
broker an `authorization` / `oidcAuthorization` that refuses, and a
|
|
773
|
+
`deviceCodePresenter` that routes the code to wherever a person can see it —
|
|
774
|
+
or refuses. The provider then runs on its stored secret and refresh token;
|
|
775
|
+
when a login is needed, `prepare()` / `rejected()` answer Oops — whose
|
|
776
|
+
refusal carries fixed wording, never the message of what your collaborator
|
|
777
|
+
threw — and nothing is written:
|
|
778
|
+
|
|
779
|
+
```typescript
|
|
780
|
+
const refuseLogin = {
|
|
781
|
+
authorize: async () => {
|
|
782
|
+
throw new LoginRequiredError('Run mcp-auth to log in');
|
|
783
|
+
},
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
const broker = new AuthBroker({
|
|
787
|
+
serviceKeyStore: myKeyStore,
|
|
788
|
+
sessionStore: mySessionStore,
|
|
789
|
+
authorization: () => refuseLogin,
|
|
790
|
+
oidcAuthorization: () => refuseLogin,
|
|
791
|
+
deviceCodePresenter: () => ({
|
|
792
|
+
present: async () => {
|
|
793
|
+
throw new LoginRequiredError('No one to show a device code to');
|
|
794
|
+
},
|
|
795
|
+
}),
|
|
796
|
+
});
|
|
797
|
+
```
|
|
798
|
+
|
|
132
799
|
### Custom Browser Auth Port
|
|
133
800
|
|
|
134
801
|
How a login is conducted — including which port the local OAuth2 callback
|
|
@@ -156,41 +823,156 @@ const token = await broker.getToken('TRIAL');
|
|
|
156
823
|
const newToken = await broker.refreshToken('TRIAL');
|
|
157
824
|
```
|
|
158
825
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
826
|
+
**Which provider answers.**
|
|
827
|
+
|
|
828
|
+
- **No `provider` option** — the destination's own: the very provider
|
|
829
|
+
`getProvider('TRIAL')` hands out, from the same per-destination cache. A
|
|
830
|
+
connector and the token API in one process therefore share one token, one
|
|
831
|
+
refresh token and one renewal: a `refreshToken()` while the connector renews
|
|
832
|
+
in `rejected()` joins that renewal, and a token the connector renewed is
|
|
833
|
+
what `getToken()` answers next. The destination must state a grant that
|
|
834
|
+
obtains a token — every grant but `none` (see the table under *A Provider
|
|
835
|
+
for a Connector*); a `none` destination is a `DestinationConfigError`
|
|
836
|
+
naming `provider`, before any provider is built.
|
|
837
|
+
- **A `provider` option** — yours, as in 3.x (see *Basic Usage*): the factory
|
|
838
|
+
is called once per destination (concurrent first calls build once; a call
|
|
839
|
+
after one that threw builds again), an instance serves every destination.
|
|
840
|
+
|
|
841
|
+
**What is written.** The session secret alone, in one `saveSession` — the
|
|
842
|
+
token, or the cookies of a SAML result (`tokenType: 'saml'`), its
|
|
843
|
+
`expiresAt`, the refresh token (the stored one carried forward when the
|
|
844
|
+
result has none), and what the secret is bound to (`issuedFor`, `issuedBy`,
|
|
845
|
+
see *A Secret Is Bound to Its Resource and Issuer*). Never `serviceUrl`,
|
|
846
|
+
`authType` or a client: they are means, and live in the key store.
|
|
847
|
+
|
|
848
|
+
- Without a `provider`, the token API writes nothing itself: the provider's
|
|
849
|
+
`onTokens` writes every token it obtains (see *Persistence and `flush()`*),
|
|
850
|
+
and a cache hit writes nothing.
|
|
851
|
+
- With a `provider`, the token API writes after every answer, cache hits
|
|
852
|
+
included, through the same write path. `issuedFor` is the `serviceUrl` it
|
|
853
|
+
resolved, with the SAP client; `issuedBy` the client a factory was handed —
|
|
854
|
+
an instance is handed none, so what it obtains is bound to no issuer and
|
|
855
|
+
`getProvider` never seeds from it. Both are fixed when your provider is
|
|
856
|
+
built for the destination (an instance: at its first call for it) and kept
|
|
857
|
+
with it: a URL or client changed later never re-labels a token that
|
|
858
|
+
provider obtained — a new broker picks the change up.
|
|
859
|
+
|
|
860
|
+
**A write that fails** reaches the caller: `getToken()` / `refreshToken()`
|
|
861
|
+
throw the store's error, as raised, for the token they received — and the
|
|
862
|
+
broker keeps retrying that write on its own; `flush()` tells you when it
|
|
863
|
+
landed.
|
|
864
|
+
|
|
865
|
+
**A destination stated `basic` or `snc` has no token API.** The token API
|
|
866
|
+
reads the destination's `authType` from the key store first and, for `basic`
|
|
867
|
+
or `snc`, throws `DestinationConfigError` naming `authType` before any provider
|
|
868
|
+
is asked — a token written over such a session would replace a credential
|
|
869
|
+
that is not one. A key store that states no `authType` (a 3.x setup), or no
|
|
870
|
+
key store, is served as before.
|
|
871
|
+
|
|
872
|
+
**Two token sources, with a `provider` option.** `getProvider` never uses
|
|
873
|
+
the `provider` option: the destination states its own provider. A process
|
|
874
|
+
that gives the broker a `provider` and also calls `getProvider` for the same
|
|
875
|
+
destination has two token sources for it — each with its own token and
|
|
876
|
+
renewal, both writing to the same session. Use one of them per destination;
|
|
877
|
+
or, to hand your own provider to a connector, wrap the token API:
|
|
878
|
+
`TokenAuthProvider.from(broker.createTokenRefresher('TRIAL'))`
|
|
879
|
+
(`@mcp-abap-adt/auth-providers`), whose every renewal the token API writes.
|
|
162
880
|
|
|
163
881
|
### Creating Token Refresher for DI
|
|
164
882
|
|
|
165
|
-
|
|
883
|
+
`createTokenRefresher(destination)` returns an `ITokenRefresher` (from
|
|
884
|
+
`@mcp-abap-adt/interfaces-auth`) bound to one destination — `getToken()` is
|
|
885
|
+
`broker.getToken(destination)`, `refreshToken()` is
|
|
886
|
+
`broker.refreshToken(destination)` — for a connection of your own that asks
|
|
887
|
+
for a token per request and for a new one after a 401. It is unchanged since
|
|
888
|
+
3.x and not deprecated: calm-server injects it into its own connection.
|
|
166
889
|
|
|
167
890
|
```typescript
|
|
168
|
-
import { AuthBroker } from '@mcp-abap-adt/auth-broker';
|
|
169
|
-
import { JwtAbapConnection } from '@mcp-abap-adt/connection';
|
|
170
|
-
|
|
171
|
-
// Create broker
|
|
172
|
-
const broker = new AuthBroker({
|
|
173
|
-
sessionStore: mySessionStore,
|
|
174
|
-
serviceKeyStore: myServiceKeyStore,
|
|
175
|
-
provider: myProviderFactory,
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// Create token refresher for specific destination
|
|
179
891
|
const tokenRefresher = broker.createTokenRefresher('TRIAL');
|
|
180
892
|
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// - getToken(): the provider's current token (broker.getToken)
|
|
186
|
-
// - refreshToken(): a new token, never the cached one (broker.refreshToken)
|
|
893
|
+
// In your own connection:
|
|
894
|
+
const token = await tokenRefresher.getToken();
|
|
895
|
+
// …the server answered 401:
|
|
896
|
+
const newToken = await tokenRefresher.refreshToken();
|
|
187
897
|
```
|
|
188
898
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
899
|
+
A `@mcp-abap-adt/connection` 10 connector takes an `IAuthProvider` instead:
|
|
900
|
+
give it `await broker.getProvider('TRIAL')`.
|
|
901
|
+
|
|
902
|
+
## Migrating from 4.0.0
|
|
903
|
+
|
|
904
|
+
4.1.0 is a minor: nothing to change to keep 4.0.0's behaviour. Without a
|
|
905
|
+
`clientAuthentication` strategy every destination is served as before and
|
|
906
|
+
nothing certificate-related is read. What a 4.0.0 consumer may still notice:
|
|
907
|
+
|
|
908
|
+
1. **A refusal's message gains a hint.** A UAA, OIDC or `saml2_bearer`
|
|
909
|
+
destination whose key store answers no client (or one without a client
|
|
910
|
+
id) is still a `DestinationConfigError` with the same `missingFields`; its
|
|
911
|
+
message now ends with `; a certificate client needs a clientAuthentication
|
|
912
|
+
strategy`. Match on `missingFields` or the class, never the message.
|
|
913
|
+
2. **Dependencies.** `@mcp-abap-adt/interfaces-auth-broker` `^1.2.0` (the
|
|
914
|
+
optional `IServiceKeyStore.getClientCertificate` and `IClientCertificate`),
|
|
915
|
+
`@mcp-abap-adt/interfaces-auth` `^3.2.0` and `@mcp-abap-adt/auth-providers`
|
|
916
|
+
`^5.3.0` (`IClientAuthentication`). A key store that reads x509 keys is
|
|
917
|
+
auth-stores `^3.3.0`; an older store simply has no certificate client.
|
|
918
|
+
3. **New exports:** `fromServiceKeyCertificate`, `fromServiceKeySecret`, and
|
|
919
|
+
the types `ClientAuthenticationStrategy`, `ClientAuthenticationContext`,
|
|
920
|
+
`ClientAuthenticationGrant`, `FromServiceKeySecretOptions`,
|
|
921
|
+
`TokenProviderClient`, `IClientCertificate`, `IClientAuthentication`.
|
|
922
|
+
|
|
923
|
+
**Adopting a strategy** (`clientAuthentication`, see *How the Client
|
|
924
|
+
Authenticates*) changes, for the destinations it applies to:
|
|
925
|
+
|
|
926
|
+
- **No secret goes to the provider** — the strategy's answer does; a
|
|
927
|
+
provider's constructor refuses both.
|
|
928
|
+
- **A secret is bound to the client's identity**: a secret key and an x509
|
|
929
|
+
key of the same client id at the same `uaaUrl` share one session, so
|
|
930
|
+
switching between them keeps the token and the refresh token; a destination
|
|
931
|
+
stating no parseable `serviceUrl` reuses a session stored without
|
|
932
|
+
`issuedFor`, which 4.0.0 never did.
|
|
933
|
+
- **The token API with a factory of yours** passes the fourth argument; seeds
|
|
934
|
+
your factory with a stored refresh token, token or cookies only when the
|
|
935
|
+
session is bound here (4.0.0 carried the stored refresh token over whatever
|
|
936
|
+
its binding); refuses a destination that states no `authType` / `grantType`;
|
|
937
|
+
and turns a throwing factory into a `DestinationConfigError` naming
|
|
938
|
+
`provider`, `clientAuthentication`.
|
|
939
|
+
|
|
940
|
+
## Migrating from 3.x
|
|
941
|
+
|
|
942
|
+
4.0.0 is a major: what a 3.x consumer must do, in short (the full list is the
|
|
943
|
+
[CHANGELOG](CHANGELOG.md)'s 4.0.0 entry).
|
|
944
|
+
|
|
945
|
+
1. **Session store: `@mcp-abap-adt/auth-stores` 3 or later** (`^3.1.0` for
|
|
946
|
+
the binding), or one of your own on the
|
|
947
|
+
`@mcp-abap-adt/interfaces-auth-broker` contract. The broker writes the
|
|
948
|
+
secret alone — token or cookies, `expiresAt`, refresh token, `issuedFor`,
|
|
949
|
+
`issuedBy` — and auth-stores 1.x/2.x's session stores refuse such a write
|
|
950
|
+
(their `AbapSessionStore` throws without `serviceUrl`): they are not
|
|
951
|
+
supported. A custom store must keep `issuedFor` and `issuedBy`.
|
|
952
|
+
2. **State the means in a key store**, not in the session: `authType`, and
|
|
953
|
+
for `jwt` / `saml` a `grantType`; the client; the URL. For a SAP service
|
|
954
|
+
key, `new AbapServiceKeyStore(dir, { grantType: 'authorization_code' })`
|
|
955
|
+
(or `XsuaaServiceKeyStore`); otherwise `EnvDestinationStore(dir, {
|
|
956
|
+
fallback })`. A 3.x session file is a readable destination as it is once
|
|
957
|
+
it states `SAP_AUTH_TYPE` (and `SAP_GRANT_TYPE` for `jwt` / `saml`).
|
|
958
|
+
3. **A connector of `@mcp-abap-adt/connection` 10** takes `await
|
|
959
|
+
broker.getProvider(destination)` — no `getToken` before connecting, no
|
|
960
|
+
token refresher. Give the broker the collaborators its grants need
|
|
961
|
+
(`authorization`, `oidcAuthorization`, `deviceCodePresenter`,
|
|
962
|
+
`samlCookies`, `assertionReplayStore`); there is no default.
|
|
963
|
+
4. **The token API** keeps its calls, signatures and 3.x reads with a
|
|
964
|
+
`provider` of yours; it writes the secret alone, refuses a destination
|
|
965
|
+
stated `basic` or `snc`, and throws a failed write while the broker retries
|
|
966
|
+
it.
|
|
967
|
+
5. **`getConnectionConfig` / `getAuthorizationConfig`** compose the key
|
|
968
|
+
store's means with the session's secret; a URL or client kept only in the
|
|
969
|
+
session store is not found any more.
|
|
970
|
+
6. **New:** catch `DestinationConfigError`; call `flush()` on shutdown;
|
|
971
|
+
write `bindingOf(means)` beside a credential you hand over yourself.
|
|
972
|
+
7. **Contracts:** `interfaces-auth` 3, `interfaces-auth-sap` 2,
|
|
973
|
+
`interfaces-auth-broker` 1.1 (the store contracts moved there from
|
|
974
|
+
`interfaces-auth-sap`), auth-providers 5. The commands are
|
|
975
|
+
`@mcp-abap-adt/auth-broker-cli` 2.0.0.
|
|
194
976
|
|
|
195
977
|
## Migrating from 2.2.0
|
|
196
978
|
|
|
@@ -218,111 +1000,75 @@ const connection = new JwtAbapConnection(config, tokenRefresher);
|
|
|
218
1000
|
|
|
219
1001
|
### Environment Variables
|
|
220
1002
|
|
|
221
|
-
|
|
1003
|
+
The library reads no environment variable: the stores take their directories
|
|
1004
|
+
from their constructors, and the providers their settings from the
|
|
1005
|
+
destination's means and the collaborators you give the broker. (In the
|
|
1006
|
+
repository's test suites, `DEBUG_BROKER=true` — or `DEBUG_AUTH_BROKER=true`,
|
|
1007
|
+
`DEBUG=broker` — turns on the test logger, its level from `AUTH_LOG_LEVEL`.)
|
|
222
1008
|
|
|
223
|
-
|
|
1009
|
+
### Logging
|
|
224
1010
|
|
|
225
|
-
|
|
1011
|
+
Pass an `ILogger` as the constructor's second argument (for instance
|
|
1012
|
+
`DefaultLogger` from `@mcp-abap-adt/logger`); without one, nothing is logged.
|
|
226
1013
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
- `DEBUG_AUTH_BROKER` - Long name (backward compatibility)
|
|
234
|
-
- Same as `DEBUG_BROKER`, but longer name
|
|
235
|
-
- Example: `DEBUG_AUTH_BROKER=true npm test`
|
|
236
|
-
|
|
237
|
-
- `LOG_LEVEL` - Control log verbosity level
|
|
238
|
-
- Values: `debug`, `info`, `warn`, `error` (default: `info`)
|
|
239
|
-
- `debug` - All messages including detailed debug information
|
|
240
|
-
- `info` - Informational messages, warnings, and errors
|
|
241
|
-
- `warn` - Warnings and errors only
|
|
242
|
-
- `error` - Errors only
|
|
243
|
-
- Example: `LOG_LEVEL=debug DEBUG_BROKER=true npm test`
|
|
1014
|
+
**What is logged:** the broker's initialization (whether a key store is
|
|
1015
|
+
given; the `provider` option: `none`, `factory` or `instance`), each provider build (the destination's `authType` and grant, whether
|
|
1016
|
+
it was seeded), each session secret saved (token or cookies, whether a
|
|
1017
|
+
refresh token came back, the expiry), a stored secret discarded because it is
|
|
1018
|
+
bound elsewhere (the destination only), and each failed write with the error's
|
|
1019
|
+
class name.
|
|
244
1020
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
- Example: `DEBUG=true npm test` or `DEBUG=broker npm test` or `DEBUG=auth-broker npm test`
|
|
1021
|
+
**What is never logged:** any part of a token, refresh token, password or
|
|
1022
|
+
secret — not a prefix, not a suffix — nor a store error's message, which may
|
|
1023
|
+
quote what was being written.
|
|
249
1024
|
|
|
250
|
-
|
|
251
|
-
- `DEBUG_STORES` (short) or `DEBUG_AUTH_STORES` (long) - Enable logging for `@mcp-abap-adt/auth-stores` package
|
|
252
|
-
- `DEBUG_PROVIDER` (short) or `DEBUG_AUTH_PROVIDERS` (long) - Enable logging for `@mcp-abap-adt/auth-providers` package
|
|
1025
|
+
With `DefaultLogger` at `info`:
|
|
253
1026
|
|
|
254
|
-
**Legacy Support**: `DEBUG_AUTH_LOG` is still supported for backward compatibility (equivalent to `DEBUG_BROKER=true LOG_LEVEL=debug`)
|
|
255
|
-
|
|
256
|
-
### Logging Features
|
|
257
|
-
|
|
258
|
-
When logging is enabled (via `DEBUG_BROKER=true` or `DEBUG_AUTH_BROKER=true`), the broker provides detailed structured logging:
|
|
259
|
-
|
|
260
|
-
**What is logged:** broker initialization (which stores, instance or factory),
|
|
261
|
-
provider builds (whether credentials, a refresh token and a stored token were
|
|
262
|
-
there), and each token saved (token type, grant, whether a refresh token came
|
|
263
|
-
back, expiry). Store read failures are logged as warnings.
|
|
264
|
-
|
|
265
|
-
**What is never logged:** any part of a token, refresh token or secret — not a
|
|
266
|
-
prefix, not a suffix. A log line says whether a token is there, never what it is.
|
|
267
|
-
|
|
268
|
-
Example output with `DEBUG_BROKER=true LOG_LEVEL=info`:
|
|
269
1027
|
```
|
|
270
|
-
[INFO] ℹ️ [
|
|
1028
|
+
[INFO] ℹ️ [AuthBroker] Session secret saved for TRIAL
|
|
1029
|
+
{"credential":"token","hasRefreshToken":true,"expiresAt":1790000000000}
|
|
271
1030
|
```
|
|
272
1031
|
|
|
273
|
-
**Note**: Logging only works when a logger is explicitly provided to the broker constructor. The broker will not output anything to console if no logger is passed.
|
|
274
|
-
|
|
275
1032
|
### File Structure
|
|
276
1033
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
1034
|
+
With `@mcp-abap-adt/auth-stores` 3 one `<destination>.env` file can hold a
|
|
1035
|
+
destination whole, each store reading and writing only its own keys — the
|
|
1036
|
+
means through `EnvDestinationStore`, the secret through `AbapSessionStore`
|
|
1037
|
+
(or both in separate directories, as you compose them):
|
|
280
1038
|
|
|
281
1039
|
```env
|
|
1040
|
+
# The means — EnvDestinationStore (ABAP_DESTINATION_VARS); the broker never writes them
|
|
282
1041
|
SAP_URL=https://your-system.abap.us10.hana.ondemand.com
|
|
283
1042
|
SAP_CLIENT=100
|
|
284
|
-
|
|
285
|
-
|
|
1043
|
+
SAP_AUTH_TYPE=jwt
|
|
1044
|
+
SAP_GRANT_TYPE=authorization_code
|
|
286
1045
|
SAP_UAA_URL=https://your-account.authentication.us10.hana.ondemand.com
|
|
287
1046
|
SAP_UAA_CLIENT_ID=client_id
|
|
288
1047
|
SAP_UAA_CLIENT_SECRET=client_secret
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
#### Environment File for XSUAA (`{destination}.env`)
|
|
292
|
-
|
|
293
|
-
For XSUAA connections (reduced scope), use `XSUAA_*` environment variables:
|
|
294
|
-
|
|
295
|
-
```env
|
|
296
|
-
XSUAA_MCP_URL=https://your-mcp-server.cfapps.eu10.hana.ondemand.com
|
|
297
|
-
XSUAA_JWT_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
298
|
-
XSUAA_REFRESH_TOKEN=refresh_token_string
|
|
299
|
-
XSUAA_UAA_URL=https://your-account.authentication.eu10.hana.ondemand.com
|
|
300
|
-
XSUAA_UAA_CLIENT_ID=client_id
|
|
301
|
-
XSUAA_UAA_CLIENT_SECRET=client_secret
|
|
302
|
-
```
|
|
303
|
-
|
|
304
|
-
**Note**: `XSUAA_MCP_URL` is optional - it's not part of authentication, only needed for making requests. The token and UAA credentials are sufficient for authentication.
|
|
305
|
-
|
|
306
|
-
#### Environment File for BTP (`{destination}.env`)
|
|
307
1048
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
BTP_UAA_URL=https://your-account.authentication.eu10.hana.ondemand.com
|
|
315
|
-
BTP_UAA_CLIENT_ID=client_id
|
|
316
|
-
BTP_UAA_CLIENT_SECRET=client_secret
|
|
317
|
-
BTP_SAP_CLIENT=100
|
|
318
|
-
BTP_LANGUAGE=EN
|
|
1049
|
+
# The secret — AbapSessionStore (ABAP_SESSION_VARS); what the broker writes
|
|
1050
|
+
SAP_JWT_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
1051
|
+
SAP_EXPIRES_AT=1790000000000
|
|
1052
|
+
SAP_REFRESH_TOKEN=refresh_token_string
|
|
1053
|
+
SAP_ISSUED_FOR=https://your-system.abap.us10.hana.ondemand.com:443?sap-client=100
|
|
1054
|
+
SAP_ISSUED_BY=https://your-account.authentication.us10.hana.ondemand.com:443?client_id=client_id
|
|
319
1055
|
```
|
|
320
1056
|
|
|
321
|
-
|
|
1057
|
+
A client that authenticates with a certificate states `SAP_UAA_CLIENT_CERT_PATH`,
|
|
1058
|
+
`SAP_UAA_CLIENT_KEY_PATH` and `SAP_UAA_CERT_URL` — paths and a URL, never PEM —
|
|
1059
|
+
in place of `SAP_UAA_CLIENT_SECRET` (see *How the Client Authenticates*).
|
|
1060
|
+
SAML cookies are `SAP_SESSION_COOKIES_B64` in place of `SAP_JWT_TOKEN`. The
|
|
1061
|
+
XSUAA stores use the same names with `XSUAA_` (`XSUAA_DESTINATION_VARS`,
|
|
1062
|
+
`XSUAA_SESSION_VARS`; the URL is `XSUAA_MCP_URL`). Every means key
|
|
1063
|
+
(`SAP_USERNAME`, `SAP_SNC_*`, `SAP_OIDC_*`, `SAP_SAML_*`, …) is listed in
|
|
1064
|
+
[USAGE.md](../../docs/using/USAGE.md#environment-variables).
|
|
322
1065
|
|
|
323
|
-
#### Service Key File
|
|
1066
|
+
#### Service Key File (`{destination}.json`)
|
|
324
1067
|
|
|
325
|
-
|
|
1068
|
+
A SAP service key, read by `AbapServiceKeyStore` (an ABAP environment key:
|
|
1069
|
+
the client and the ABAP URL) or `XsuaaServiceKeyStore` (an XSUAA key: the
|
|
1070
|
+
client). A key cannot state which grant the destination uses, so the store is
|
|
1071
|
+
told: `new AbapServiceKeyStore(dir, { grantType: 'authorization_code' })`.
|
|
326
1072
|
|
|
327
1073
|
```json
|
|
328
1074
|
{
|
|
@@ -335,41 +1081,25 @@ Standard ABAP service key format:
|
|
|
335
1081
|
}
|
|
336
1082
|
```
|
|
337
1083
|
|
|
338
|
-
|
|
1084
|
+
An XSUAA key carries no URL of the resource it authorizes for: state it as
|
|
1085
|
+
means in an `EnvDestinationStore` (`XSUAA_MCP_URL`, with
|
|
1086
|
+
`XSUAA_DESTINATION_VARS`) whose `fallback` is the `XsuaaServiceKeyStore`.
|
|
339
1087
|
|
|
340
|
-
|
|
1088
|
+
An x509 XSUAA key (created with `{"credential-type": "x509"}`) holds a
|
|
1089
|
+
certificate client instead of a secret — the broker uses it only beside a
|
|
1090
|
+
`clientAuthentication` strategy (see *How the Client Authenticates*):
|
|
341
1091
|
|
|
342
1092
|
```json
|
|
343
1093
|
{
|
|
344
|
-
"url": "https://your-account.authentication.
|
|
345
|
-
"
|
|
346
|
-
"
|
|
347
|
-
"
|
|
1094
|
+
"url": "https://your-account.authentication.us10.hana.ondemand.com",
|
|
1095
|
+
"clientid": "sb-your-app!t12345",
|
|
1096
|
+
"certificate": "-----BEGIN CERTIFICATE-----\n...",
|
|
1097
|
+
"key": "-----BEGIN RSA PRIVATE KEY-----\n...",
|
|
1098
|
+
"certurl": "https://your-account.authentication.cert.us10.hana.ondemand.com",
|
|
1099
|
+
"credential-type": "x509"
|
|
348
1100
|
}
|
|
349
1101
|
```
|
|
350
1102
|
|
|
351
|
-
**Note**: For XSUAA service keys, `apiurl` is prioritized over `url` for UAA authorization if present.
|
|
352
|
-
|
|
353
|
-
## XSUAA vs BTP Authentication
|
|
354
|
-
|
|
355
|
-
This package supports two types of BTP authentication:
|
|
356
|
-
|
|
357
|
-
### XSUAA (Reduced Scope)
|
|
358
|
-
- **Purpose**: Access BTP services with limited scopes
|
|
359
|
-
- **Service Key**: Contains only UAA credentials (no ABAP URL)
|
|
360
|
-
- **Session Store**: `XsuaaSessionStore` (uses `XSUAA_*` environment variables)
|
|
361
|
-
- **Authentication**: Client credentials grant type (no browser required)
|
|
362
|
-
- **MCP URL**: Optional, provided separately (from YAML config `mcp_url`, parameter, or request header)
|
|
363
|
-
- **Use Case**: Accessing BTP services like MCP servers with reduced permissions
|
|
364
|
-
|
|
365
|
-
### BTP (Full Scope for ABAP)
|
|
366
|
-
- **Purpose**: Access ABAP systems with full roles and scopes
|
|
367
|
-
- **Service Key**: Contains UAA credentials and ABAP URL
|
|
368
|
-
- **Session Store**: `BtpSessionStore` (uses `BTP_*` environment variables)
|
|
369
|
-
- **Authentication**: Browser-based OAuth2 (like ABAP) or refresh token
|
|
370
|
-
- **ABAP URL**: Required, from service key or YAML configuration
|
|
371
|
-
- **Use Case**: Accessing ABAP systems in BTP with full permissions
|
|
372
|
-
|
|
373
1103
|
## Responsibilities and Design Principles
|
|
374
1104
|
|
|
375
1105
|
### Core Development Principle
|
|
@@ -377,7 +1107,7 @@ This package supports two types of BTP authentication:
|
|
|
377
1107
|
**Interface-Only Communication**: This package follows a fundamental development principle: **all interactions with external dependencies happen ONLY through interfaces**. The code knows **NOTHING beyond what is defined in the interfaces**.
|
|
378
1108
|
|
|
379
1109
|
This means:
|
|
380
|
-
- Does not know about concrete
|
|
1110
|
+
- Does not know about concrete store classes (e.g., `AbapSessionStore`); the provider classes it knows only to construct the one a destination states, and hands out as `IAuthProvider`
|
|
381
1111
|
- Does not know about internal data structures or methods not defined in interfaces
|
|
382
1112
|
- Does not make assumptions about implementation behavior beyond interface contracts
|
|
383
1113
|
- Does not access properties or methods not explicitly defined in interfaces
|
|
@@ -394,11 +1124,11 @@ The `@mcp-abap-adt/auth-broker` package defines **interfaces** and provides **or
|
|
|
394
1124
|
|
|
395
1125
|
#### What AuthBroker Does
|
|
396
1126
|
|
|
397
|
-
- **Resolves what the stores hold**: the
|
|
398
|
-
- **Builds or reuses the provider**:
|
|
1127
|
+
- **Resolves what the stores hold**: the means from the key store, the secret from the session store (and, for a `provider` of yours, the 3.x reads: the service URL and the UAA credentials, session first)
|
|
1128
|
+
- **Builds or reuses the provider**: one per destination — the one the destination states, shared by `getProvider` and the token API; or your factory, called once per destination
|
|
399
1129
|
- **Asks the provider once**: `getTokens()` for `getToken()`, `refreshTokens()` for `refreshToken()` — no retries, no fallbacks
|
|
400
|
-
- **Persists the answer**: token or session cookies,
|
|
401
|
-
- **Works with interfaces only**: `IServiceKeyStore`, `ISessionStore`, `IRefreshableTokenProvider`
|
|
1130
|
+
- **Persists the answer**: the session secret alone — token or session cookies, `expiresAt`, the refresh token, and what it is bound to — to `sessionStore`, retrying a write that fails
|
|
1131
|
+
- **Works with interfaces only**: `IServiceKeyStore`, `ISessionStore`, `IAuthProvider`, `IRefreshableTokenProvider`
|
|
402
1132
|
|
|
403
1133
|
#### What AuthBroker Does NOT Do
|
|
404
1134
|
|
|
@@ -412,19 +1142,15 @@ The `@mcp-abap-adt/auth-broker` package defines **interfaces** and provides **or
|
|
|
412
1142
|
|
|
413
1143
|
The **consumer** (application using `AuthBroker`) is responsible for:
|
|
414
1144
|
|
|
415
|
-
1. **
|
|
416
|
-
- **ABAP systems**:
|
|
417
|
-
- **
|
|
418
|
-
- **XSUAA services**:
|
|
1145
|
+
1. **Composing the stores and collaborators**: an `IServiceKeyStore` for the means and an `ISessionStore` for the secret — with auth-stores 3:
|
|
1146
|
+
- **ABAP systems (a SAP service key)**: `AbapServiceKeyStore(dir, { grantType })`, alone or as the `fallback` of an `EnvDestinationStore`, and `AbapSessionStore` (or `SafeAbapSessionStore`, in memory)
|
|
1147
|
+
- **Destinations without a key** (basic, SNC, OIDC, SAML, `none`): `EnvDestinationStore(dir)` and `AbapSessionStore`
|
|
1148
|
+
- **XSUAA services**: `EnvDestinationStore(dir, { variables: XSUAA_DESTINATION_VARS, fallback: new XsuaaServiceKeyStore(keys, { grantType }) })` and `XsuaaSessionStore` (or `SafeXsuaaSessionStore`)
|
|
1149
|
+
- the collaborator options the destinations' grants need — or, for the token API the 3.x way, an `IRefreshableTokenProvider` of your own
|
|
419
1150
|
|
|
420
|
-
2. **
|
|
421
|
-
- The session is created with `serviceUrl` before calling `AuthBroker.getToken()`, OR
|
|
422
|
-
- The session store implementation handles `serviceUrl` retrieval internally (e.g., from `serviceKeyStore`)
|
|
1151
|
+
2. **Stating the means in the key store**: `authType`, `grantType` and the fields its row reads, the client, and `serviceUrl` — for the token API with a `provider` of yours, the service URL comes from the session, else the key store.
|
|
423
1152
|
|
|
424
|
-
3. **
|
|
425
|
-
- `AbapSessionStore`: Requires `sapUrl` (maps to `serviceUrl` in `IConnectionConfig`)
|
|
426
|
-
- `BtpSessionStore`: Does not require `serviceUrl` (uses `mcpUrl` instead)
|
|
427
|
-
- `XsuaaSessionStore`: Does not require `serviceUrl` (MCP URL is optional)
|
|
1153
|
+
3. **A session store that takes the secret alone**: the broker writes the token or cookies, `expiresAt`, the refresh token, `issuedFor` and `issuedBy` in one `saveSession`, and nothing else. auth-stores 3's session stores do; auth-stores 1.x/2.x's `AbapSessionStore` and `SafeAbapSessionStore` refuse a write without `serviceUrl` (measured on 1.2.3, 1.2.4, 2.0.0), so a token API on them throws after every token. A custom store must also keep `issuedFor` and `issuedBy`.
|
|
428
1154
|
|
|
429
1155
|
### Store Responsibilities
|
|
430
1156
|
|
|
@@ -432,10 +1158,7 @@ Concrete `ISessionStore` implementations are responsible for:
|
|
|
432
1158
|
|
|
433
1159
|
- **Handling their own data format**: Each store knows its internal data format (e.g., `AbapSessionData`, `BtpBaseSessionData`)
|
|
434
1160
|
- **Converting between formats**: Converting between `IConfig`/`IConnectionConfig` and internal storage format
|
|
435
|
-
- **
|
|
436
|
-
- Retrieve it from `serviceKeyStore` if not provided in `IConnectionConfig`, OR
|
|
437
|
-
- Use existing value from current session if available, OR
|
|
438
|
-
- Throw an error if neither is available (depending on implementation)
|
|
1161
|
+
- **Taking the secret alone**: a session store is written the secret and its binding only, and keeps what it held beside them (auth-stores 3 refuses means outright)
|
|
439
1162
|
|
|
440
1163
|
### Provider Responsibilities
|
|
441
1164
|
|
|
@@ -449,7 +1172,7 @@ Concrete `IRefreshableTokenProvider` implementations are responsible for:
|
|
|
449
1172
|
### Design Principles
|
|
450
1173
|
|
|
451
1174
|
1. **Interface-Only Communication** (Core Principle): All interactions with external dependencies happen **ONLY through interfaces**. The code knows **NOTHING beyond what is defined in the interfaces** (see [Core Development Principle](#core-development-principle) above)
|
|
452
|
-
2. **Dependency Inversion Principle (DIP)**: `AuthBroker` depends on abstractions (`IServiceKeyStore`, `ISessionStore`, `IRefreshableTokenProvider`), not concrete implementations
|
|
1175
|
+
2. **Dependency Inversion Principle (DIP)**: `AuthBroker` depends on abstractions (`IServiceKeyStore`, `ISessionStore`, `IAuthProvider`, `IRefreshableTokenProvider`), not concrete store implementations; the providers it builds it hands out as `IAuthProvider`
|
|
453
1176
|
3. **Single Responsibility**: Each component has a single, well-defined responsibility:
|
|
454
1177
|
- `AuthBroker`: Orchestration — resolving, asking, persisting
|
|
455
1178
|
- `ISessionStore`: Session data storage and retrieval
|
|
@@ -468,54 +1191,143 @@ Concrete `IRefreshableTokenProvider` implementations are responsible for:
|
|
|
468
1191
|
new AuthBroker(
|
|
469
1192
|
config: {
|
|
470
1193
|
sessionStore: ISessionStore; // required
|
|
471
|
-
serviceKeyStore?: IServiceKeyStore; //
|
|
472
|
-
provider
|
|
1194
|
+
serviceKeyStore?: IServiceKeyStore; // getProvider needs it
|
|
1195
|
+
provider?: // the token API's, optional
|
|
473
1196
|
| IRefreshableTokenProvider
|
|
474
1197
|
| ((
|
|
475
1198
|
destination: string,
|
|
476
1199
|
authConfig: IAuthorizationConfig | null,
|
|
477
1200
|
connConfig: IConnectionConfig,
|
|
1201
|
+
client?: TokenProviderClient, // only beside clientAuthentication (4.1.0)
|
|
478
1202
|
) => IRefreshableTokenProvider);
|
|
1203
|
+
// Collaborators, each a function of the destination, called once per
|
|
1204
|
+
// build, never disposed by the broker:
|
|
1205
|
+
authorization?: (destination: string, grant: StrategyGrant) => IAuthorizationStrategy<string>; // authorization_code, passcode, saml2_pure, saml2_bearer
|
|
1206
|
+
oidcAuthorization?: (destination: string) => IAuthorizationStrategy<OidcCallbackResult>; // oidc_authorization_code
|
|
1207
|
+
deviceCodePresenter?: (destination: string) => IDeviceCodePresenter; // device_code
|
|
1208
|
+
samlCookies?: (destination: string) => (samlResponse: string) => Promise<string>; // saml2_pure
|
|
1209
|
+
assertionReplayStore?: (destination: string) => IAssertionReplayStore; // saml2_pure, saml2_bearer
|
|
1210
|
+
// How a client authenticates (4.1.0): every grant that authenticates one
|
|
1211
|
+
clientAuthentication?: ClientAuthenticationStrategy;
|
|
479
1212
|
},
|
|
480
1213
|
logger?: ILogger,
|
|
481
1214
|
)
|
|
482
1215
|
```
|
|
483
1216
|
|
|
484
1217
|
**Parameters:**
|
|
485
|
-
- `config.sessionStore` - **Required** -
|
|
486
|
-
- `config.serviceKeyStore` -
|
|
487
|
-
- `config.provider` -
|
|
1218
|
+
- `config.sessionStore` - **Required** - The session secret: the token or cookies, `expiresAt`, the refresh token, and what it is bound to (`issuedFor`, `issuedBy`). For the token API with a `provider`, its `serviceUrl`, or the service key's, is required (3.x's reads).
|
|
1219
|
+
- `config.serviceKeyStore` - The means: `authType`, `grantType`, the client, basic's user and password, the SNC, OIDC and SAML fields, `serviceUrl`. **Required by `getProvider`**, which has no other source of means.
|
|
1220
|
+
- `config.provider` - The token API's source (`getToken`, `refreshToken`, `createTokenRefresher`): a provider instance, used for every destination, or a factory (`TokenProviderFactory`), called once per destination and seeded with what the stores hold (see *Basic Usage*); beside a `clientAuthentication` strategy, with a fourth argument (`TokenProviderClient`, see *How the Client Authenticates*). Not used by `getProvider`. Without it, the token API asks the provider `getProvider` builds for the destination (see *Getting Tokens*); with neither it nor a `serviceKeyStore`, the token API throws `DestinationConfigError` naming both.
|
|
1221
|
+
- `config.authorization` - The interactive strategy of `jwt` / `authorization_code`, `jwt` / `passcode`, `saml` / `saml2_pure` and `saml` / `saml2_bearer`, as a function of the destination and the grant (see *A Provider for a Connector*).
|
|
1222
|
+
- `config.oidcAuthorization` - The interactive strategy of `jwt` / `oidc_authorization_code`.
|
|
1223
|
+
- `config.deviceCodePresenter` - Where `jwt` / `device_code` shows the user the verification URL and code.
|
|
1224
|
+
- `config.samlCookies` - `saml2_pure`: turns the validated SAMLResponse into the system's session cookies.
|
|
1225
|
+
- `config.assertionReplayStore` - The replay store the SAML validators record each assertion in.
|
|
1226
|
+
- `config.clientAuthentication` - How the client of every grant that authenticates one (the UAA and OIDC grants, `saml2_bearer`) authenticates to the authorization server: a `ClientAuthenticationStrategy` — `fromServiceKeyCertificate()`, `fromServiceKeySecret({ encoding })`, or your own composition. Called once per build; absent, the client secret as in 4.0.0 and nothing certificate-related is read (see *How the Client Authenticates*).
|
|
1227
|
+
- Each collaborator option is required only by the rows that use it (missing → `DestinationConfigError` naming it); the broker supplies no default, calls it once per build, and disposes nothing it returns.
|
|
488
1228
|
- `logger` - Optional logger. If not provided, nothing is logged.
|
|
489
1229
|
|
|
490
1230
|
**Available Implementations:**
|
|
491
|
-
- **
|
|
492
|
-
- **
|
|
1231
|
+
- **Means** (auth-stores 3): `EnvDestinationStore(directory, { fallback? })`, `AbapServiceKeyStore(directory, { grantType?, log? })`, `XsuaaServiceKeyStore(directory, { grantType?, log? })`
|
|
1232
|
+
- **Secret** (auth-stores 3): `AbapSessionStore(directory, log?)`, `SafeAbapSessionStore(log?)`, `XsuaaSessionStore(directory, log?)`, `SafeXsuaaSessionStore(log?)`, `EnvFileSessionStore(file, log?)`
|
|
1233
|
+
- **Providers** (auth-providers 5): `AuthorizationCodeProvider(...)`, `ClientCredentialsProvider(...)` and the rest, for a `provider` of yours
|
|
493
1234
|
|
|
494
1235
|
#### Methods
|
|
495
1236
|
|
|
1237
|
+
##### `getProvider(destination: string): Promise<IAuthProvider>`
|
|
1238
|
+
|
|
1239
|
+
The `IAuthProvider` the destination states, built from the key store's means
|
|
1240
|
+
and the session store's secret (see *A Provider for a Connector*). Cached per
|
|
1241
|
+
destination; a failed build is retried on the next call. Throws
|
|
1242
|
+
`DestinationConfigError` for a destination that lacks what its type needs, and
|
|
1243
|
+
passes a store failure other than absence on as the store raised it.
|
|
1244
|
+
|
|
1245
|
+
##### `flush(): Promise<void>`
|
|
1246
|
+
|
|
1247
|
+
Every session write still pending gets one more attempt. Resolves when all
|
|
1248
|
+
have landed; rejects with an `AggregateError` naming the destinations whose
|
|
1249
|
+
store still refuses (its `errors` carry each destination and the store
|
|
1250
|
+
error's class only, never its message) — the broker keeps retrying them. See *Persistence and `flush()`*.
|
|
1251
|
+
|
|
496
1252
|
##### `getToken(destination: string): Promise<string>`
|
|
497
1253
|
|
|
498
|
-
1.
|
|
499
|
-
2.
|
|
500
|
-
3.
|
|
501
|
-
4.
|
|
502
|
-
5. Returns the token.
|
|
1254
|
+
1. Reads the destination's `authType` from the key store: `basic` or `snc` is a `DestinationConfigError` naming `authType`, before any provider is asked.
|
|
1255
|
+
2. **Without a `provider` option:** takes the provider `getProvider(destination)` hands out (the same cache; a `none` destination is a `DestinationConfigError` naming `provider`), calls `getTokens()` once, and writes nothing itself — the provider's `onTokens` wrote anything new.
|
|
1256
|
+
3. **With one:** resolves `serviceUrl` (session, else service key; an error if neither has one), builds the provider on first use (factory form) seeded with the credentials, the stored refresh token and the stored token — or uses the instance — calls `getTokens()` once, and writes the result: the secret alone, through the broker's write path.
|
|
1257
|
+
4. Throws the store's error if the write of this token failed (the broker keeps retrying it); else returns the token.
|
|
503
1258
|
|
|
504
1259
|
##### `refreshToken(destination: string): Promise<string>`
|
|
505
1260
|
|
|
506
1261
|
The same, with `provider.refreshTokens()`: a new token, never the cached one —
|
|
507
|
-
for a caller whose token the server has just refused.
|
|
1262
|
+
for a caller whose token the server has just refused. A renewal already in
|
|
1263
|
+
flight for the destination (a connector's, in `rejected()`) is joined.
|
|
508
1264
|
|
|
509
1265
|
##### `getAuthorizationConfig(destination)` / `getConnectionConfig(destination)`
|
|
510
1266
|
|
|
511
|
-
The
|
|
1267
|
+
The two stores composed, each for its role:
|
|
1268
|
+
|
|
1269
|
+
- `getConnectionConfig` — the service key store's means with the session's
|
|
1270
|
+
secret (`authorizationToken`, `sessionCookies`, `expiresAt`) laid over them;
|
|
1271
|
+
`null` when neither store holds anything.
|
|
1272
|
+
- `getAuthorizationConfig` — the service key store's client (`uaaUrl`,
|
|
1273
|
+
`uaaClientId`, `uaaClientSecret`) with the session's refresh token; `null`
|
|
1274
|
+
when the key store has no client — an x509 key's included: it never answers
|
|
1275
|
+
a certificate or a key.
|
|
1276
|
+
|
|
1277
|
+
Means a session store answers (a client, a URL, an `authType`) are not read,
|
|
1278
|
+
nor a secret a key store answers. Up to 3.1.0 both answered the session's
|
|
1279
|
+
configuration whole, and the key's only when the session had none.
|
|
512
1280
|
|
|
513
1281
|
##### `createTokenRefresher(destination): ITokenRefresher`
|
|
514
1282
|
|
|
515
|
-
`getToken()` and `refreshToken()` bound to one destination, for injection into a connection.
|
|
1283
|
+
`getToken()` and `refreshToken()` bound to one destination, for injection into a connection of your own. Unchanged since 3.x; not deprecated.
|
|
516
1284
|
|
|
517
1285
|
##### Error Handling
|
|
518
1286
|
|
|
1287
|
+
- **`DestinationConfigError`** — a destination that lacks what its type needs.
|
|
1288
|
+
`getProvider` throws it before any provider is asked; so does the token
|
|
1289
|
+
API, for the same destinations and for those below. It carries `code:
|
|
1290
|
+
'DESTINATION_CONFIG'`, `destination`, `missingFields` — field or option
|
|
1291
|
+
names only. When a provider's constructor refused (an `sncQop` outside `1`,
|
|
1292
|
+
`2`, `3`, `8`, `9`), the store field is named and the provider's own error
|
|
1293
|
+
is not kept — its message quotes the value. **No stored value
|
|
1294
|
+
reaches it**: not a password, not a token, not an `authType` that is none of
|
|
1295
|
+
the four. Thrown for:
|
|
1296
|
+
|
|
1297
|
+
| Case | `missingFields` |
|
|
1298
|
+
|---|---|
|
|
1299
|
+
| no `serviceKeyStore` option (`getProvider`) | `serviceKeyStore` |
|
|
1300
|
+
| the token API with neither a `provider` nor a `serviceKeyStore` option | `provider`, `serviceKeyStore` |
|
|
1301
|
+
| the token API on a destination stated `basic` or `snc` | `authType` |
|
|
1302
|
+
| the token API without a `provider` option, on a `jwt` / `none` or `saml` / `none` destination | `provider` |
|
|
1303
|
+
| the key store has no means for the destination — whatever the session holds | `authType` |
|
|
1304
|
+
| no `authType`, `''`, or one that is not `basic`, `jwt`, `saml`, `snc` | `authType` |
|
|
1305
|
+
| `jwt` / `saml` without `grantType`, `''`, or a pair outside the table | `grantType` |
|
|
1306
|
+
| `basic` without user or password (`''` counts as missing) | `username`, `password` — each that is missing |
|
|
1307
|
+
| `snc` without `sncPartnerName` | `sncPartnerName` |
|
|
1308
|
+
| `snc` whose settings the provider refuses | the store field, e.g. `sncQop` |
|
|
1309
|
+
| `jwt` / `none` without a token in the session | `authorizationToken` |
|
|
1310
|
+
| `saml` / `none` without cookies in the session | `sessionCookies` |
|
|
1311
|
+
| `jwt` / `none`, `saml` / `none` whose stored `issuedFor` is not the destination's resource, or is absent, or the means state no `serviceUrl` | `issuedFor` |
|
|
1312
|
+
| `jwt` / `none`, `saml` / `none` whose means state an issuer and whose stored `issuedBy` is not it, or is absent | `issuedBy` |
|
|
1313
|
+
| a UAA grant without its client in the key store (`''` counts as missing) | `uaaUrl`, `uaaClientId`, and `uaaClientSecret` for `authorization_code` / `client_credentials` — each that is missing |
|
|
1314
|
+
| `authorization_code` / `passcode` without the `authorization` option | `authorization` |
|
|
1315
|
+
| an OIDC grant without its client's id in the key store | `uaaClientId` |
|
|
1316
|
+
| an OIDC grant with neither `oidcIssuerUrl` nor every endpoint its row reads | `oidcIssuerUrl`, and each endpoint missing: `oidcAuthorizationEndpoint` / `oidcDeviceAuthorizationEndpoint`, `oidcTokenEndpoint` |
|
|
1317
|
+
| `password` without user or password | `username`, `password` — each that is missing |
|
|
1318
|
+
| `token_exchange` without its subject | `oidcSubjectToken`, `oidcSubjectTokenType` — each that is missing |
|
|
1319
|
+
| `oidc_authorization_code` without the `oidcAuthorization` option | `oidcAuthorization` |
|
|
1320
|
+
| `device_code` without the `deviceCodePresenter` option | `deviceCodePresenter` |
|
|
1321
|
+
| a SAML grant without its trust (`''` and an empty certificate list count as missing) | `samlIdpSsoUrl`, `samlSpEntityId`, `samlIdpEntityId`, `samlIdpCertificates` — each that is missing |
|
|
1322
|
+
| `saml2_bearer` without its client | `uaaUrl`, `uaaClientId` — each that is missing |
|
|
1323
|
+
| a client row (UAA, OIDC, `saml2_bearer`) with no client id and no `clientAuthentication` strategy | as above; the message adds `a certificate client needs a clientAuthentication strategy` |
|
|
1324
|
+
| the `clientAuthentication` strategy threw, refused, or answered no client authentication; the certificate client could not be read | `clientAuthentication` (fixed words, see *How the Client Authenticates*) |
|
|
1325
|
+
| the token API with a `clientAuthentication` strategy and a factory, on a destination that states no `jwt` / `saml` type | `authType`, `grantType` |
|
|
1326
|
+
| the token API's factory threw beside a `clientAuthentication` strategy | `provider`, `clientAuthentication` |
|
|
1327
|
+
| a SAML grant without its collaborators | `authorization`, `samlCookies` (`saml2_pure`), `assertionReplayStore` — each that is missing |
|
|
1328
|
+
| a certificate the validator cannot read | `samlIdpCertificates` |
|
|
1329
|
+
| a `samlClockSkewMs` that is not a whole, non-negative number | `samlClockSkewMs` |
|
|
1330
|
+
|
|
519
1331
|
- **Provider errors propagate unchanged** — the same object, with its class,
|
|
520
1332
|
`code`, `missingFields` and `cause`: auth-providers' `ValidationError`,
|
|
521
1333
|
`BrowserAuthError`, `AssertionValidationError`, network errors (`ECONNREFUSED`,
|
|
@@ -532,12 +1344,25 @@ The session's configuration, else the service key's, else `null`.
|
|
|
532
1344
|
the new token was written. (The session stores of
|
|
533
1345
|
auth-stores answer an unreadable session file with `null` themselves, so it
|
|
534
1346
|
reads as absent before the broker sees it.)
|
|
535
|
-
- **Store writes
|
|
1347
|
+
- **Store writes**: a write that fails reaches the token API's caller — the
|
|
1348
|
+
store's error, as raised, for the token it received — and is retried by the
|
|
1349
|
+
broker all the same. A `getProvider` provider's write never fails the
|
|
1350
|
+
authentication; it is retried, and `flush()` reports it.
|
|
536
1351
|
- **A provider result without a token** is an error.
|
|
537
1352
|
|
|
538
1353
|
```typescript
|
|
1354
|
+
import { DestinationConfigError } from '@mcp-abap-adt/auth-broker';
|
|
539
1355
|
import { ValidationError } from '@mcp-abap-adt/auth-providers';
|
|
540
1356
|
|
|
1357
|
+
try {
|
|
1358
|
+
const provider = await broker.getProvider('TRIAL');
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
if (error instanceof DestinationConfigError) {
|
|
1361
|
+
logger.error(`${error.destination} lacks: ${error.missingFields.join(', ')}`);
|
|
1362
|
+
}
|
|
1363
|
+
throw error;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
541
1366
|
try {
|
|
542
1367
|
const token = await broker.getToken('TRIAL');
|
|
543
1368
|
} catch (error) {
|
|
@@ -550,38 +1375,32 @@ try {
|
|
|
550
1375
|
|
|
551
1376
|
#### Secrets in the Session Store
|
|
552
1377
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
1378
|
+
Everything the broker writes — a `getProvider` provider's token and the
|
|
1379
|
+
token API's alike — is the secret alone: `authorizationToken` (or
|
|
1380
|
+
`sessionCookies`), `expiresAt`, `refreshToken`, with `issuedFor` and
|
|
1381
|
+
`issuedBy`, in one `saveSession`, and nothing else (see *Persistence and
|
|
1382
|
+
`flush()`*). Never the client secret, never `serviceUrl` or `authType`:
|
|
1383
|
+
`setConnectionConfig` and `setAuthorizationConfig` are not called. A store
|
|
1384
|
+
that merges a write into what it holds keeps a client or URL it held beside
|
|
1385
|
+
the secret; auth-stores 3 holds the secret alone.
|
|
558
1386
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
process's provider — the XSUAA stores too, from auth-stores 1.2.3.
|
|
1387
|
+
The stored refresh token comes back through `loadSession()`, which the broker
|
|
1388
|
+
reads to seed the next process's provider.
|
|
562
1389
|
|
|
563
1390
|
### Token Providers
|
|
564
1391
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
- No browser interaction required
|
|
570
|
-
- No refresh token provided
|
|
1392
|
+
Provider implementations live in `@mcp-abap-adt/auth-providers` 5. `getProvider`
|
|
1393
|
+
builds the one the destination states (see *A Provider for a Connector*); for
|
|
1394
|
+
the token API the 3.x way you pass one yourself — an `IRefreshableTokenProvider`
|
|
1395
|
+
or a factory:
|
|
571
1396
|
|
|
572
|
-
- **`
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
by success, failure, timeout, or cancellation
|
|
580
|
-
- Uses browser-based OAuth2 flow (if no refresh token)
|
|
581
|
-
- Uses refresh token if available
|
|
582
|
-
- Provides refresh token for future use
|
|
583
|
-
|
|
584
|
-
**Example Usage:**
|
|
1397
|
+
- **`ClientCredentialsProvider`** — `client_credentials`: no user, no browser,
|
|
1398
|
+
no refresh token.
|
|
1399
|
+
- **`AuthorizationCodeProvider`** — `authorization_code`: refreshes by the
|
|
1400
|
+
stored refresh token, and logs in through the `authorization` strategy you
|
|
1401
|
+
give it — there is no default. `browserCallbackStrategy({ browser?, port?,
|
|
1402
|
+
timeoutMs? })` builds the usual one (callback port `61001` unless `port` is
|
|
1403
|
+
given; held only for the duration of a login).
|
|
585
1404
|
|
|
586
1405
|
```typescript
|
|
587
1406
|
import { AuthBroker } from '@mcp-abap-adt/auth-broker';
|
|
@@ -593,7 +1412,7 @@ import { ClientCredentialsProvider } from '@mcp-abap-adt/auth-providers';
|
|
|
593
1412
|
|
|
594
1413
|
// XSUAA, client_credentials: credentials from the service key
|
|
595
1414
|
const xsuaaBroker = new AuthBroker({
|
|
596
|
-
sessionStore: new XsuaaSessionStore('/path/to/sessions'
|
|
1415
|
+
sessionStore: new XsuaaSessionStore('/path/to/sessions'),
|
|
597
1416
|
serviceKeyStore: new XsuaaServiceKeyStore('/path/to/keys'),
|
|
598
1417
|
provider: (destination, authConfig) => {
|
|
599
1418
|
if (!authConfig) throw new Error(`No UAA credentials for ${destination}`);
|
|
@@ -642,13 +1461,36 @@ destination.
|
|
|
642
1461
|
Without `test-config.yaml` the template is read, its placeholders disable every
|
|
643
1462
|
integration case, and the run reaches no system.
|
|
644
1463
|
|
|
1464
|
+
### The x509 Live Check
|
|
1465
|
+
|
|
1466
|
+
`npm run test:live:x509` (from the repository root or this package) runs
|
|
1467
|
+
`client_credentials` with an x509 XSUAA service key against a real BTP
|
|
1468
|
+
subaccount: it builds, creates an `xsuaa` / `application` instance
|
|
1469
|
+
(`credential-types: ["binding-secret", "x509"]`) and a key made with
|
|
1470
|
+
`{"credential-type": "x509"}`, runs `src/__tests__/live/x509.live.test.ts`
|
|
1471
|
+
— the broker with `fromServiceKeyCertificate()`, `mcp-auth --client-auth
|
|
1472
|
+
certificate`, `generate-env-from-service-key --client-auth certificate`, a
|
|
1473
|
+
fresh broker over each written destination, and a failing run that leaves the
|
|
1474
|
+
previous destination untouched and prints no PEM — and removes everything,
|
|
1475
|
+
also when a test fails. Not in `npm test`, not in CI.
|
|
1476
|
+
|
|
1477
|
+
It refuses to run unless `XSUAA_CF_API`, `XSUAA_CF_ORG` and `XSUAA_CF_SPACE`
|
|
1478
|
+
equal exactly what `cf target` shows (`cf login -a <api> --sso -o <org> -s
|
|
1479
|
+
<space>` first). It touches only what it created: each resource is recorded
|
|
1480
|
+
with its GUID in a ledger, `tests/live/x509/.local/owned` (gitignored, with
|
|
1481
|
+
the key and its PEM files, owner-only), bound to that API, org and space, and
|
|
1482
|
+
re-checked before every reuse or delete; a name held by anything else is
|
|
1483
|
+
refused. A failed teardown exits non-zero and keeps `.local/` — run
|
|
1484
|
+
`tests/live/x509/teardown.sh` with the same three variables. `X509_KEEP=1`
|
|
1485
|
+
keeps the environment for another run; `-- -t "(b)"` runs one case. Last run:
|
|
1486
|
+
BTP trial, 2026-10-05, all five cases passed.
|
|
1487
|
+
|
|
645
1488
|
## Documentation
|
|
646
1489
|
|
|
647
1490
|
Complete documentation is available in the repository's [`docs/`](../../docs/) directory:
|
|
648
1491
|
|
|
649
1492
|
- **[Architecture](../../docs/architecture/ARCHITECTURE.md)** - System architecture and design decisions
|
|
650
|
-
- **[
|
|
651
|
-
- **[Development Roadmap](../../docs/development/DEVELOPMENT_ROADMAP.md)** - Development roadmap and future plans
|
|
1493
|
+
- **[Testing](../../docs/development/TESTING.md)** - Where the suites live, what they need, the release checks
|
|
652
1494
|
- **[Installation](../../docs/installing/INSTALLATION.md)** - Installation and setup guide
|
|
653
1495
|
- **[Usage](../../docs/using/USAGE.md)** - API reference and usage examples
|
|
654
1496
|
|