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