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