@mcp-abap-adt/auth-broker 3.0.4 → 4.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 +448 -1
- package/README.md +723 -533
- package/dist/AuthBroker.d.ts +178 -29
- package/dist/AuthBroker.d.ts.map +1 -1
- package/dist/AuthBroker.js +494 -101
- package/dist/DestinationConfigError.d.ts +27 -0
- package/dist/DestinationConfigError.d.ts.map +1 -0
- package/dist/DestinationConfigError.js +36 -0
- package/dist/SessionWriter.d.ts +54 -0
- package/dist/SessionWriter.d.ts.map +1 -0
- package/dist/SessionWriter.js +135 -0
- package/dist/binding.d.ts +95 -0
- package/dist/binding.d.ts.map +1 -0
- package/dist/binding.js +207 -0
- package/dist/bindingOf.d.ts +36 -0
- package/dist/bindingOf.d.ts.map +1 -0
- package/dist/bindingOf.js +53 -0
- package/dist/destinations.d.ts +139 -0
- package/dist/destinations.d.ts.map +1 -0
- package/dist/destinations.js +429 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/providers/ITokenProvider.d.ts +2 -1
- package/dist/providers/ITokenProvider.d.ts.map +1 -1
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.js +2 -2
- package/dist/stores/index.d.ts +2 -2
- package/dist/stores/index.js +2 -2
- package/dist/stores/interfaces.d.ts +4 -3
- package/dist/stores/interfaces.d.ts.map +1 -1
- package/dist/stores/interfaces.js +2 -2
- package/dist/types.d.ts +5 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -1
- package/package.json +25 -45
- package/CONTRIBUTORS.md +0 -33
- package/dist/__tests__/helpers/configHelpers.d.ts +0 -59
- package/dist/__tests__/helpers/configHelpers.d.ts.map +0 -1
- package/dist/__tests__/helpers/configHelpers.js +0 -206
- package/dist/__tests__/helpers/netHelpers.d.ts +0 -3
- package/dist/__tests__/helpers/netHelpers.d.ts.map +0 -1
- package/dist/__tests__/helpers/netHelpers.js +0 -63
- package/dist/__tests__/helpers/testLogger.d.ts +0 -7
- package/dist/__tests__/helpers/testLogger.d.ts.map +0 -1
- package/dist/__tests__/helpers/testLogger.js +0 -54
- package/dist/bin/mcp-auth.js +0 -752
- package/dist/bin/mcp-sso.js +0 -825
- package/dist/bin/mcpSsoConfig.js +0 -566
- package/dist/bin/samlMetadata.js +0 -175
- package/dist/bin/workDir.js +0 -79
package/README.md
CHANGED
|
@@ -1,24 +1,39 @@
|
|
|
1
1
|
# @mcp-abap-adt/auth-broker
|
|
2
2
|
[](https://stand-with-ukraine.pp.ua)
|
|
3
3
|
|
|
4
|
-
A per-destination
|
|
5
|
-
`TRIAL` —
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
4
|
+
A per-destination credential broker for SAP BTP and ABAP systems. For a destination — a name,
|
|
5
|
+
such as `TRIAL` — `getProvider` builds the `IAuthProvider` the destination states (basic, SNC, a
|
|
6
|
+
UAA, OIDC or SAML grant, or a credential handed over) from the *means* in the service key store
|
|
7
|
+
and the *secret* in the session store, ready for a `@mcp-abap-adt/connection` 10 connector, and
|
|
8
|
+
stores back every token or set of SAML session cookies that provider obtains or renews. The
|
|
9
|
+
token API (`getToken`, `refreshToken`, `createTokenRefresher`) serves whoever wants a token and
|
|
10
|
+
nothing else. It decides nothing about tokens itself: whether the cached token is still good,
|
|
11
|
+
when to refresh and when to log in is the provider's call (`@mcp-abap-adt/auth-providers`), and
|
|
12
|
+
where means and secrets live is the stores' (`@mcp-abap-adt/auth-stores`, or your own).
|
|
13
|
+
|
|
14
|
+
**Upgrading from 3.x?** See [*Migrating from 3.x*](#migrating-from-3x) — 4.0.0 needs a session
|
|
15
|
+
store that takes the secret alone (auth-stores 3 or later), means stated in a key store, and
|
|
16
|
+
the contracts of `interfaces-auth` 3.
|
|
17
|
+
|
|
18
|
+
The `mcp-auth` and `mcp-sso` commands that write destination files are in
|
|
19
|
+
[`@mcp-abap-adt/auth-broker-cli`](../auth-broker-cli/README.md) (2.0.0, on this
|
|
20
|
+
version), in the same repository. Up to 3.0.4 they shipped in this package; from 3.1.0 this package
|
|
21
|
+
has no `bin`. Install the commands with
|
|
22
|
+
`npm i -g @mcp-abap-adt/auth-broker-cli` — after
|
|
23
|
+
`npm uninstall -g @mcp-abap-adt/auth-broker` if you had installed this one
|
|
24
|
+
globally for them.
|
|
13
25
|
|
|
14
26
|
## Features
|
|
15
27
|
|
|
16
|
-
-
|
|
28
|
+
- 🔌 **A credential for a connector**: `getProvider(destination)` builds the `IAuthProvider` the destination states — basic, SNC, the UAA grants (authorization code, client credentials, passcode), the OIDC grants (authorization code with PKCE, device code, password, token exchange), the SAML grants (session cookies, bearer token), or a credential handed over — from the service key store's means and the session store's secret
|
|
29
|
+
- 💾 **What a provider obtains is stored**: every token or set of SAML session cookies a `getProvider` provider obtains or renews — at `prepare()`, on expiry, or after a 401 in `rejected()` — is written to the session store, the secret alone with what it is bound to; a failed write is retried by the broker, and `flush()` tells you whether everything landed
|
|
30
|
+
- 🔒 **A secret goes only where it was obtained**: a stored token is used only for the resource it was obtained for, from the issuer and client that issued it — otherwise it is discarded and the provider logs in afresh
|
|
31
|
+
- 🎯 **Per destination**: one provider per destination name, shared by `getProvider` and the token API — one token, one refresh token, one renewal in flight
|
|
17
32
|
- 🔄 **Provider-driven token lifecycle**: The provider decides whether its cached token is still good, refreshes it, or logs in; the broker persists what it returns
|
|
33
|
+
- 🪙 **A token API for whoever wants a token and nothing else**: `getToken`, `refreshToken` and `createTokenRefresher` on the destination's own provider, or on one you give the broker, as in 3.x
|
|
18
34
|
- ⚡ **Forced refresh**: `refreshToken()` obtains a new token even when the cached one looks valid — for a caller holding a 401
|
|
19
35
|
- 🧾 **JWT or SAML cookies**: what the provider returns is saved as a token or as session cookies
|
|
20
36
|
- 🔑 **No secrets copied**: The client secret stays in the service key; the session store gets tokens only
|
|
21
|
-
- 🧰 **CLIs**: `mcp-auth` and `mcp-sso` produce `.env`/JSON session files, SAML trust read from metadata
|
|
22
37
|
|
|
23
38
|
## Installation
|
|
24
39
|
|
|
@@ -34,10 +49,13 @@ supported as well.
|
|
|
34
49
|
|
|
35
50
|
### Basic Usage
|
|
36
51
|
|
|
37
|
-
|
|
38
|
-
|
|
52
|
+
This is the token API with a provider of your own — the 3.x way, which keeps
|
|
53
|
+
working. The broker takes a session store, an optional service key store, and
|
|
54
|
+
a token provider implementing `IRefreshableTokenProvider` (from
|
|
39
55
|
`@mcp-abap-adt/interfaces-auth`) — or a factory that builds one per
|
|
40
|
-
destination
|
|
56
|
+
destination. Without a `provider` the token API serves what the destination
|
|
57
|
+
states, on the provider `getProvider` builds (see *Getting Tokens*); for a
|
|
58
|
+
`@mcp-abap-adt/connection` 10 connector, use `getProvider` itself.
|
|
41
59
|
|
|
42
60
|
```typescript
|
|
43
61
|
import { AuthBroker } from '@mcp-abap-adt/auth-broker';
|
|
@@ -81,18 +99,358 @@ The factory receives:
|
|
|
81
99
|
- `connConfig` — the session's connection config, with `serviceUrl` resolved
|
|
82
100
|
(from the session, else the service key) and the token stored last.
|
|
83
101
|
|
|
102
|
+
These are 3.x's reads, in 3.x's order, kept for this path: a session store
|
|
103
|
+
that still answers a client or a URL is read first. auth-stores 3's session
|
|
104
|
+
stores answer neither, so the key store's are what is found. What the broker
|
|
105
|
+
writes back is the session secret alone (see *Getting Tokens*).
|
|
106
|
+
|
|
84
107
|
A provider instance can be passed instead of a factory; it is then used as
|
|
85
108
|
given, for every destination, and the broker seeds it with nothing:
|
|
86
109
|
|
|
87
110
|
```typescript
|
|
88
111
|
const broker = new AuthBroker({
|
|
89
112
|
sessionStore: new AbapSessionStore('/path/to/sessions'),
|
|
90
|
-
provider: new AuthorizationCodeProvider({
|
|
113
|
+
provider: new AuthorizationCodeProvider({
|
|
114
|
+
uaaUrl, clientId, clientSecret,
|
|
115
|
+
authorization: browserCallbackStrategy({ browser: 'system' }),
|
|
116
|
+
}),
|
|
91
117
|
});
|
|
92
118
|
```
|
|
93
119
|
|
|
94
|
-
> `AuthorizationCodeProvider` and the other `@mcp-abap-adt/auth-providers`
|
|
95
|
-
> providers implement `IRefreshableTokenProvider` from auth
|
|
120
|
+
> `AuthorizationCodeProvider` and the other `@mcp-abap-adt/auth-providers` 5
|
|
121
|
+
> providers implement `IRefreshableTokenProvider` (from `@mcp-abap-adt/interfaces-auth`
|
|
122
|
+
> 3). Since auth-providers 5 the interactive ones take their `authorization`
|
|
123
|
+
> strategy explicitly — none is built for you.
|
|
124
|
+
|
|
125
|
+
### A Provider for a Connector: `getProvider`
|
|
126
|
+
|
|
127
|
+
`getProvider(destination)` returns the `IAuthProvider` (from
|
|
128
|
+
`@mcp-abap-adt/interfaces-auth` 3) that a `@mcp-abap-adt/connection` 10
|
|
129
|
+
connector takes as it is. The destination states which provider it gets; the
|
|
130
|
+
broker reads two stores, each for one role:
|
|
131
|
+
|
|
132
|
+
- **the means** — `authType`, `grantType`, basic's user and password, the SNC,
|
|
133
|
+
OIDC and SAML fields, `serviceUrl`, the client — from the **service key store**
|
|
134
|
+
(`getConnectionConfig`), and only from there;
|
|
135
|
+
- **the secret** — the token or session cookies, `expiresAt`, the refresh
|
|
136
|
+
token — from the **session store** (`loadSession`), and only from there.
|
|
137
|
+
|
|
138
|
+
Means a session store happens to answer are not read, and neither is a token
|
|
139
|
+
or cookies a key store happens to answer. Nothing is inferred: the
|
|
140
|
+
destination's `authType` (and `grantType`, for `jwt` and `saml`) decides the
|
|
141
|
+
provider, never which other fields are present.
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
import { AuthBroker } from '@mcp-abap-adt/auth-broker';
|
|
145
|
+
|
|
146
|
+
const broker = new AuthBroker({
|
|
147
|
+
serviceKeyStore: myKeyStore, // the means — required by getProvider
|
|
148
|
+
sessionStore: mySessionStore, // the secret
|
|
149
|
+
// no `provider`: that option is the token API's source, not getProvider's
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const provider = await broker.getProvider('DEV'); // an IAuthProvider
|
|
153
|
+
// const connection = createAbapConnection({ url, provider }, logger) …
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
| `authType` / `grantType` | Provider (auth-providers 5.2) | Read from the key store | Read from the session store |
|
|
157
|
+
|---|---|---|---|
|
|
158
|
+
| `basic` (no grant read) | `new BasicAuthProvider(username, password)` | `username`, `password` | nothing |
|
|
159
|
+
| `snc` (no grant read) | `SncLogonProvider.forSecureLoginClient({ partnerName, qop, sncLib, myName, logger })` | `sncPartnerName` (required); `sncQop`, `sncLib`, `sncMyName` when set | nothing |
|
|
160
|
+
| `jwt` / `authorization_code` | `AuthorizationCodeProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret`; `serviceUrl`, `sapClient` for the binding | the seed: `authorizationToken`, `refreshToken`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
|
|
161
|
+
| `jwt` / `client_credentials` | `ClientCredentialsProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret`; `serviceUrl`, `sapClient` for the binding | nothing (the row takes the client alone) |
|
|
162
|
+
| `jwt` / `passcode` | `UaaPasscodeProvider` | the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret` (`''` = a public client); `serviceUrl`, `sapClient` for the binding | the seed: `authorizationToken`, `refreshToken`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
|
|
163
|
+
| `jwt` / `oidc_authorization_code` | `OidcBrowserProvider` (PKCE) | `uaaClientId`, `uaaClientSecret` (`''` = a public client); `oidcIssuerUrl`, or `oidcAuthorizationEndpoint` + `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` for the binding | the seed — used only when `issuedFor` and `issuedBy` match |
|
|
164
|
+
| `jwt` / `device_code` | `OidcDeviceFlowProvider` | the client as above; `oidcIssuerUrl`, or `oidcDeviceAuthorizationEndpoint` + `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` | the seed, as above |
|
|
165
|
+
| `jwt` / `password` | `OidcPasswordProvider` | the client as above; `username`, `password`; `oidcIssuerUrl`, or `oidcTokenEndpoint`; `oidcScopes`; `serviceUrl`, `sapClient` | the seed, as above |
|
|
166
|
+
| `jwt` / `token_exchange` | `OidcTokenExchangeProvider` (RFC 8693) | the client as above; `oidcSubjectToken`, `oidcSubjectTokenType`; `oidcAudience`, `oidcActorToken`, `oidcActorTokenType` when set; `oidcScopes`, joined by one space into its `scope`; `oidcIssuerUrl`, or `oidcTokenEndpoint`; `serviceUrl`, `sapClient` | the seed, as above (it has no refresh grant: a renewal exchanges again) |
|
|
167
|
+
| `saml` / `saml2_pure` | `Saml2PureProvider` | `samlIdpSsoUrl`, `samlSpEntityId`, `samlIdpEntityId` (the expected issuer), `samlIdpCertificates`; `samlAcsUrl`, `samlRelayState`, `samlIdpInitiated`, `samlClockSkewMs` when set; `serviceUrl`, `sapClient` for the binding | the seed: `sessionCookies`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
|
|
168
|
+
| `saml` / `saml2_bearer` | `Saml2BearerProvider` (RFC 7522) | the same SAML fields; `samlTokenUrl` when set; the client: `uaaUrl`, `uaaClientId`, `uaaClientSecret` (`''` = a public client); `serviceUrl`, `sapClient` | the seed: `authorizationToken`, `refreshToken`, `expiresAt` — used only when `issuedFor` and `issuedBy` match |
|
|
169
|
+
| `jwt` / `none` | `TokenAuthProvider.fixed(authorizationToken)` | `authType`, `grantType`, `serviceUrl` (+ `sapClient`); an issuer, when stated: `oidcIssuerUrl`, or the client's `uaaUrl` + `uaaClientId` | `authorizationToken` (required), `issuedFor` (required to match); `issuedBy` when the means state an issuer |
|
|
170
|
+
| `saml` / `none` | `new SamlAuthProvider(sessionCookies)` | `authType`, `grantType`, `serviceUrl` (+ `sapClient`); `samlAcsUrl` when stated | `sessionCookies` (required), `issuedFor` (required to match); `issuedBy` when `samlAcsUrl` is stated |
|
|
171
|
+
|
|
172
|
+
**No provider reads `serviceUrl`.** The URL of the system is where the
|
|
173
|
+
connector connects, not authorization data: no token provider reads it — a
|
|
174
|
+
UAA grant needs the client and `uaaUrl` — so `getProvider` neither requires it
|
|
175
|
+
nor passes it to a provider. Give the connector its URL from your key store
|
|
176
|
+
(`getConnectionConfig`), as the key store answers it. The broker reads it,
|
|
177
|
+
with `sapClient`, for one thing only: to bind a stored secret to the resource
|
|
178
|
+
it was obtained for (see *A Secret Is Bound to Its Resource and Issuer*). A
|
|
179
|
+
token destination without it still gets its provider, but no stored secret
|
|
180
|
+
is reused for it.
|
|
181
|
+
|
|
182
|
+
`none` is how a handed-over credential is stated: the key store says
|
|
183
|
+
`grantType: 'none'`, and the token or cookies live in the session. The SNC
|
|
184
|
+
row takes the contract's own defaults when a field is absent — the library is
|
|
185
|
+
discovered (`SNC_LIB_64`, `SNC_LIB`, the Secure Login Client's install path)
|
|
186
|
+
when `sncLib` is, the user's SNC name comes from the credential when
|
|
187
|
+
`sncMyName` is, and `qop` is the provider's `'9'` when `sncQop` is.
|
|
188
|
+
|
|
189
|
+
The allowed pairs are `jwt` with `authorization_code`, `client_credentials`,
|
|
190
|
+
`passcode`, `oidc_authorization_code`, `device_code`, `password`,
|
|
191
|
+
`token_exchange` or `none`, and `saml` with `saml2_pure`, `saml2_bearer` or
|
|
192
|
+
`none`. A pair outside them is a `DestinationConfigError` naming `grantType`.
|
|
193
|
+
|
|
194
|
+
**The UAA grants.** The client comes from the key store's
|
|
195
|
+
`getAuthorizationConfig` — never from the session store. A session is the
|
|
196
|
+
seed: the stored token is presented while it is valid (a JWT's own `exp`
|
|
197
|
+
decides; the stored `expiresAt` serves a token that carries none), and the
|
|
198
|
+
stored refresh token renews it. Without a session the provider is built
|
|
199
|
+
unseeded and obtains its first token at `prepare()`. `uaaClientSecret: ''` is
|
|
200
|
+
a public client: `passcode` takes it as no secret; `AuthorizationCodeProvider`
|
|
201
|
+
and `ClientCredentialsProvider` require a secret, so for them `''` is missing.
|
|
202
|
+
|
|
203
|
+
**The `authorization` option** is the interactive half of
|
|
204
|
+
`authorization_code` and `passcode`: a function of the destination and the
|
|
205
|
+
grant, returning the `IAuthorizationStrategy<string>` the provider logs in
|
|
206
|
+
with. The broker calls it once, when it builds that destination's provider,
|
|
207
|
+
and never disposes what it returns — whoever constructs, disposes. For
|
|
208
|
+
`passcode` the strategy is handed `<uaaUrl>/passcode` as the URL to send the
|
|
209
|
+
user to, and returns the code. There is no default: a destination whose grant
|
|
210
|
+
needs it, without it, is a `DestinationConfigError` naming `authorization`.
|
|
211
|
+
A headless process passes one that refuses (see *Headless Processes*).
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
import { browserCallbackStrategy, manualPasscodeStrategy } from '@mcp-abap-adt/auth-providers';
|
|
215
|
+
|
|
216
|
+
const broker = new AuthBroker({
|
|
217
|
+
serviceKeyStore: myKeyStore,
|
|
218
|
+
sessionStore: mySessionStore,
|
|
219
|
+
authorization: (destination, grant) =>
|
|
220
|
+
grant === 'passcode'
|
|
221
|
+
? manualPasscodeStrategy({ timeoutMs: 300_000 })
|
|
222
|
+
: browserCallbackStrategy({ timeoutMs: 120_000 }),
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**The OIDC grants.** The client is the key store's `getAuthorizationConfig`
|
|
227
|
+
again: `uaaClientId`, and `uaaClientSecret` — `''` is a public client, sent
|
|
228
|
+
with no secret. (A key store answers a client when it holds one: from
|
|
229
|
+
auth-stores 3.2.0, `EnvDestinationStore` answers one whenever `uaaClientId`
|
|
230
|
+
is stated, a missing `uaaUrl` or secret as `''` — so an OIDC destination
|
|
231
|
+
states its issuer in `oidcIssuerUrl` alone, and a public client no secret.)
|
|
232
|
+
The endpoints come from `oidcIssuerUrl`, which the provider discovers them
|
|
233
|
+
from, or — without it — from every explicit endpoint the row reads
|
|
234
|
+
(`oidcAuthorizationEndpoint`, `oidcDeviceAuthorizationEndpoint`,
|
|
235
|
+
`oidcTokenEndpoint`); without either the error names `oidcIssuerUrl` and the
|
|
236
|
+
endpoints missing. `oidcScopes` is passed as given (`token_exchange` takes
|
|
237
|
+
one `scope` string: the scopes joined by a space). The subject and actor
|
|
238
|
+
tokens of `token_exchange` and the user and password of `password` are
|
|
239
|
+
means: sent to the token endpoint, never written to the session.
|
|
240
|
+
|
|
241
|
+
**The SAML grants.** Each provider validates the assertion before anything
|
|
242
|
+
uses it, with a validator the broker composes from the destination's trust
|
|
243
|
+
and your replay store: `createSignedResponseValidator` for `saml2_pure` (the
|
|
244
|
+
Response must be signed — the cookies' system receives it whole) and
|
|
245
|
+
`createSignedAssertionValidator` for `saml2_bearer` (the Assertion must be
|
|
246
|
+
signed — the token endpoint receives it alone), from `samlIdpCertificates`
|
|
247
|
+
(PEM or base64 DER; several during a rotation), `samlClockSkewMs` and
|
|
248
|
+
`assertionReplayStore(destination)`; `samlIdpEntityId` is the issuer every
|
|
249
|
+
assertion must name. A certificate the validator cannot read, or a
|
|
250
|
+
`samlClockSkewMs` that is not a whole, non-negative number of milliseconds,
|
|
251
|
+
is a `DestinationConfigError` naming the field. `samlIdpInitiated: true`
|
|
252
|
+
declares an IdP-initiated login: no AuthnRequest, and an assertion carrying
|
|
253
|
+
no `InResponseTo` — your strategy then hands over the SAMLResponse without
|
|
254
|
+
asking for an authorization URL (a strategy that asks gets Oops from
|
|
255
|
+
`prepare()`). `saml2_pure` is seeded with the stored cookies and their
|
|
256
|
+
`expiresAt` — cookies carry no expiry of their own, so the provider keeps them
|
|
257
|
+
until the assertion's earliest `NotOnOrAfter` (less the provider's one-minute
|
|
258
|
+
margin: an identity provider whose assertions live a minute or less makes
|
|
259
|
+
every request a login); SAML has no refresh token, so a renewal is a new
|
|
260
|
+
login through your strategy. `saml2_bearer` posts the Assertion to
|
|
261
|
+
`samlTokenUrl`, else `<uaaUrl>/oauth/token`, with the client, and renews by
|
|
262
|
+
its refresh token.
|
|
263
|
+
|
|
264
|
+
**The collaborator options** — each a function of the destination, called
|
|
265
|
+
once when that destination's provider is built, never disposed by the
|
|
266
|
+
broker, and required only by the rows that use it; a row whose option is
|
|
267
|
+
missing is a `DestinationConfigError` naming it:
|
|
268
|
+
|
|
269
|
+
| Option | Rows | What it returns |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| `authorization(destination, grant)` | `authorization_code`, `passcode`, `saml2_pure`, `saml2_bearer` (the grant is passed) | the `IAuthorizationStrategy<string>` that conducts the login — for the SAML grants, the one that returns the SAMLResponse (`samlCallbackStrategy`, `manualSamlResponseStrategy`, …) |
|
|
272
|
+
| `oidcAuthorization(destination)` | `oidc_authorization_code` | an `IAuthorizationStrategy<OidcCallbackResult>` (`oidcCallbackStrategy`, or `asOidcResult(…)` over a string strategy) |
|
|
273
|
+
| `deviceCodePresenter(destination)` | `device_code` | an `IDeviceCodePresenter` that shows the user the verification URL and code (`consoleDeviceCodePresenter(logger)`, or your UI) |
|
|
274
|
+
| `samlCookies(destination)` | `saml2_pure` | `(samlResponse) => Promise<string>`: posts the validated SAMLResponse to the system's ACS and returns the session cookies it sets |
|
|
275
|
+
| `assertionReplayStore(destination)` | `saml2_pure`, `saml2_bearer` | the `IAssertionReplayStore` the validator records each assertion in, refusing one presented twice — `defaultReplayStore` (process-wide, in memory) or a shared one of yours |
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
import {
|
|
279
|
+
consoleDeviceCodePresenter,
|
|
280
|
+
defaultReplayStore,
|
|
281
|
+
oidcCallbackStrategy,
|
|
282
|
+
samlCallbackStrategy,
|
|
283
|
+
} from '@mcp-abap-adt/auth-providers';
|
|
284
|
+
|
|
285
|
+
const broker = new AuthBroker(
|
|
286
|
+
{
|
|
287
|
+
serviceKeyStore: myKeyStore,
|
|
288
|
+
sessionStore: mySessionStore,
|
|
289
|
+
authorization: (destination, grant) =>
|
|
290
|
+
grant === 'saml2_pure' || grant === 'saml2_bearer'
|
|
291
|
+
? samlCallbackStrategy({ timeoutMs: 120_000 })
|
|
292
|
+
: browserCallbackStrategy({ timeoutMs: 120_000 }),
|
|
293
|
+
oidcAuthorization: () => oidcCallbackStrategy({ timeoutMs: 120_000 }),
|
|
294
|
+
deviceCodePresenter: () => consoleDeviceCodePresenter(logger),
|
|
295
|
+
samlCookies: (destination) => (samlResponse) =>
|
|
296
|
+
postToAcs(destination, samlResponse), // yours: the system's ACS answers Set-Cookie
|
|
297
|
+
assertionReplayStore: () => defaultReplayStore,
|
|
298
|
+
},
|
|
299
|
+
logger,
|
|
300
|
+
);
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### A Secret Is Bound to Its Resource and Issuer
|
|
304
|
+
|
|
305
|
+
A token's audience, cookies' host and path, a system's client: presenting a
|
|
306
|
+
secret to a resource it was not obtained for is a leak, and a secret from
|
|
307
|
+
another authorization server or client must not stand in for this
|
|
308
|
+
destination's. So the session store keeps, beside the secret, two strings
|
|
309
|
+
(`IConnectionConfig`, `@mcp-abap-adt/interfaces-auth-broker` 1.1.0):
|
|
310
|
+
|
|
311
|
+
- **`issuedFor`** — the resource: `serviceUrl` with the SAP client, e.g.
|
|
312
|
+
`https://my-abap.example.com:443/sap/bc/adt?sap-client=100`;
|
|
313
|
+
- **`issuedBy`** — who issued it, to which client: `uaaUrl` with
|
|
314
|
+
`client_id=<uaaClientId>` for the UAA grants and `saml2_bearer`, e.g.
|
|
315
|
+
`https://sub.authentication.us10.hana.ondemand.com:443?client_id=sb-abap-trial`;
|
|
316
|
+
`oidcIssuerUrl` — else `uaaUrl` — with `client_id=<uaaClientId>` for the
|
|
317
|
+
OIDC grants; for `saml2_pure`'s cookies, the ACS of the system that set
|
|
318
|
+
them: `samlAcsUrl`, origin and path.
|
|
319
|
+
|
|
320
|
+
The broker computes both from the destination's means and compares them,
|
|
321
|
+
canonicalised on **both** sides, with what the session holds:
|
|
322
|
+
|
|
323
|
+
- **The canonical form:** scheme and host lower-cased; the port explicit
|
|
324
|
+
(`443` for `https`, `80` for `http`); the path without a trailing `/` (the
|
|
325
|
+
root path is empty); one query parameter at most — `sap-client` (the means'
|
|
326
|
+
`sapClient` wins over a `sap-client` already in `serviceUrl`) or
|
|
327
|
+
`client_id` — parsed and re-encoded, so a value percent-encoded on one side
|
|
328
|
+
and plain on the other compares equal; no user info, no other parameter,
|
|
329
|
+
no fragment. A URL that does not parse binds nothing. A store keeps the
|
|
330
|
+
strings as given and need not canonicalise them.
|
|
331
|
+
- **The grants that obtain a secret** (UAA, OIDC, SAML)**:** a stored secret seeds the provider only when **both**
|
|
332
|
+
stored values equal the computed ones. Otherwise — either different, or
|
|
333
|
+
absent on either side (no `serviceUrl` in the means, an OIDC destination
|
|
334
|
+
stating neither `oidcIssuerUrl` nor `uaaUrl`, a `saml2_pure` one without
|
|
335
|
+
`samlAcsUrl`, a session written without them) — the secret is not used,
|
|
336
|
+
**refresh token included**: the
|
|
337
|
+
provider is built as with no session and logs in afresh by its grant, and
|
|
338
|
+
the log says only `<destination>: secret bound to another resource,
|
|
339
|
+
discarded` — never a URI, never a token. The new secret is written with
|
|
340
|
+
both fields.
|
|
341
|
+
- **The `none` rows** present a credential the broker cannot obtain again, so
|
|
342
|
+
a mismatch is refused, not discarded: a `DestinationConfigError` naming
|
|
343
|
+
`issuedFor` when the stored resource differs or is absent (or the means
|
|
344
|
+
state no `serviceUrl`), and naming `issuedBy` when the means state an issuer
|
|
345
|
+
(`oidcIssuerUrl` or the client for `jwt`, `samlAcsUrl` for `saml` — then
|
|
346
|
+
canonicalised as origin and path) and the stored one differs or is absent.
|
|
347
|
+
Means that state no issuer leave `issuedBy` uncompared.
|
|
348
|
+
- **The binding does not check the token.** Its audience stays the
|
|
349
|
+
resource's to enforce; the broker compares two strings and parses no token.
|
|
350
|
+
- **`bindingOf(means, client?)` — for a consumer that hands over a
|
|
351
|
+
credential.** A `none` destination presents a token or cookies the broker
|
|
352
|
+
cannot obtain; whoever writes them to the session store must write the
|
|
353
|
+
binding beside them. `bindingOf` answers exactly what the broker computes for
|
|
354
|
+
those means — the one function `getProvider` checks against and `persist`
|
|
355
|
+
writes (`issuedFor` from `serviceUrl` + `sapClient`, `issuedBy` from the
|
|
356
|
+
issuer, client or ACS the grant uses) — so a consumer never canonicalises a
|
|
357
|
+
URI itself:
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
const means = await keyStore.getConnectionConfig('DEV'); // saml / none
|
|
361
|
+
await sessionStore.saveSession('DEV', {
|
|
362
|
+
sessionCookies: cookies,
|
|
363
|
+
...bindingOf(means ?? {}), // { issuedFor: 'https://dev.example.com:443?sap-client=100' }
|
|
364
|
+
});
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
A destination that states no `jwt` / `saml` type or no grant binds nothing
|
|
368
|
+
(`{}`). `mcp-sso … --cookie` writes its cookies this way.
|
|
369
|
+
|
|
370
|
+
**What you meet:**
|
|
371
|
+
|
|
372
|
+
1. **A custom `ISessionStore`** (a database, a message log, a secret store)
|
|
373
|
+
must persist `issuedFor` and `issuedBy` beside the secret, and answer
|
|
374
|
+
them from `loadSession`. One that drops them still type-checks, but the
|
|
375
|
+
broker then never reuses its sessions: every process start is a fresh
|
|
376
|
+
login — a browser each time for an interactive grant.
|
|
377
|
+
2. **A headless process whose strategy refuses logins** gets Oops ("login
|
|
378
|
+
required") from `prepare()` / `rejected()` on a mismatch, instead of
|
|
379
|
+
presenting a foreign secret; log in again with the CLI.
|
|
380
|
+
3. **Changing a destination's URL, SAP client, UAA or client** costs one
|
|
381
|
+
fresh login — intended.
|
|
382
|
+
4. **Session files written before auth-stores 3.1.0** keep working: its
|
|
383
|
+
session stores answer `issuedFor` from the file's `SAP_URL` (+
|
|
384
|
+
`SAP_CLIENT`) and `issuedBy` from `SAP_UAA_URL` + `SAP_UAA_CLIENT_ID`,
|
|
385
|
+
which the 3.x broker and CLI wrote with the token — so a stored token whose
|
|
386
|
+
URL and client are the destination's is reused. XSUAA sessions written with
|
|
387
|
+
an empty URL have no `issuedFor`, and log in once.
|
|
388
|
+
|
|
389
|
+
**One provider per destination** for the broker's life, shared with the token
|
|
390
|
+
API when the broker has no `provider` option (see *Getting Tokens*):
|
|
391
|
+
concurrent first calls share one build, and a build that threw is tried again
|
|
392
|
+
on the next call. A destination rewritten from outside is picked up by a new
|
|
393
|
+
broker.
|
|
394
|
+
|
|
395
|
+
### Persistence and `flush()`
|
|
396
|
+
|
|
397
|
+
Every token provider `getProvider` builds writes what it obtains back to the
|
|
398
|
+
session store, through its `onTokens` hook, before it answers — whichever
|
|
399
|
+
moment triggered the renewal: `prepare()`, `authorize()` on expiry, or
|
|
400
|
+
`rejected()` after a 401. A renewal inside a connector is stored before the
|
|
401
|
+
connector resends.
|
|
402
|
+
|
|
403
|
+
- **The secret alone, with its binding, in one write**:
|
|
404
|
+
`saveSession(destination, { authorizationToken, expiresAt, refreshToken,
|
|
405
|
+
issuedFor, issuedBy })` — or, for `saml2_pure`'s cookies (a result of
|
|
406
|
+
`tokenType: 'saml'`), `{ sessionCookies, expiresAt, issuedFor, issuedBy }`,
|
|
407
|
+
with no refresh token: SAML has none. `saml2_bearer`'s result is a token
|
|
408
|
+
and is written as one — `issuedFor` / `issuedBy` as computed when the
|
|
409
|
+
provider was built, each left out when the means lack its source (so the
|
|
410
|
+
store clears it). No means is ever written — not `serviceUrl`, not
|
|
411
|
+
`authType`, not the client: the client secret lives in the key store, and
|
|
412
|
+
the broker never writes the key store (its contract has no write method).
|
|
413
|
+
`expiresAt` is the result's, else the `expires_in` it reported counted from
|
|
414
|
+
when it arrived. A result without a refresh token keeps the one the session
|
|
415
|
+
holds — when that one is bound where the new secret is; one bound to
|
|
416
|
+
another resource or issuer is not carried into it.
|
|
417
|
+
- **`basic` and `snc` are never written**: they obtain no session secret, and
|
|
418
|
+
`none` obtains nothing either. A destination the key store states as
|
|
419
|
+
`basic` or `snc` at write time is not written.
|
|
420
|
+
- **The token API writes through the same path** (see *Getting Tokens*):
|
|
421
|
+
with a `provider` of yours, every answer it gets is written as above — the
|
|
422
|
+
stored refresh token carried forward whatever its binding, as your provider
|
|
423
|
+
was seeded with it.
|
|
424
|
+
- **A failed write does not fail the authentication.** The connector goes on
|
|
425
|
+
with the token it holds; the broker keeps the result pending for that
|
|
426
|
+
destination and retries on its own — one second, doubling, capped at one
|
|
427
|
+
minute — on a timer that never keeps the process alive. A newer result
|
|
428
|
+
replaces the pending one; writes for one destination never overlap. Each
|
|
429
|
+
failure is logged by its class name, never its message. The token API's
|
|
430
|
+
caller still gets the failure: `getToken()` / `refreshToken()` throw the
|
|
431
|
+
store's error for the token they received, while the retry goes on.
|
|
432
|
+
- **`flush()`** gives every pending write one more attempt, resolves when all
|
|
433
|
+
have landed, and rejects (an `AggregateError` naming the destinations; each
|
|
434
|
+
of its `errors` is `"<destination>": <error class>` — never the store's own
|
|
435
|
+
message, which may quote what was being written) when the store still
|
|
436
|
+
refuses — the broker keeps retrying.
|
|
437
|
+
Call it on shutdown — on `SIGTERM`, before a stdio transport closes — to
|
|
438
|
+
know whether every token is stored.
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
process.on('SIGTERM', async () => {
|
|
442
|
+
try {
|
|
443
|
+
await broker.flush();
|
|
444
|
+
} catch (error) {
|
|
445
|
+
logger.error(`Tokens not stored: ${(error as Error).message}`);
|
|
446
|
+
}
|
|
447
|
+
process.exit(0);
|
|
448
|
+
});
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
**A destination that lacks what its type needs** is a `DestinationConfigError`
|
|
452
|
+
(see *Error Handling*), thrown by `getProvider` before any provider exists —
|
|
453
|
+
never a provider built only to refuse in `prepare()`.
|
|
96
454
|
|
|
97
455
|
### Headless Processes (No Browser)
|
|
98
456
|
|
|
@@ -125,6 +483,34 @@ try {
|
|
|
125
483
|
A cached token that is still valid, or a refresh token the UAA accepts, never
|
|
126
484
|
reaches the strategy.
|
|
127
485
|
|
|
486
|
+
With `getProvider` the same holds for every collaborator option: give the
|
|
487
|
+
broker an `authorization` / `oidcAuthorization` that refuses, and a
|
|
488
|
+
`deviceCodePresenter` that routes the code to wherever a person can see it —
|
|
489
|
+
or refuses. The provider then runs on its stored secret and refresh token;
|
|
490
|
+
when a login is needed, `prepare()` / `rejected()` answer Oops — whose
|
|
491
|
+
refusal carries fixed wording, never the message of what your collaborator
|
|
492
|
+
threw — and nothing is written:
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
const refuseLogin = {
|
|
496
|
+
authorize: async () => {
|
|
497
|
+
throw new LoginRequiredError('Run mcp-auth to log in');
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const broker = new AuthBroker({
|
|
502
|
+
serviceKeyStore: myKeyStore,
|
|
503
|
+
sessionStore: mySessionStore,
|
|
504
|
+
authorization: () => refuseLogin,
|
|
505
|
+
oidcAuthorization: () => refuseLogin,
|
|
506
|
+
deviceCodePresenter: () => ({
|
|
507
|
+
present: async () => {
|
|
508
|
+
throw new LoginRequiredError('No one to show a device code to');
|
|
509
|
+
},
|
|
510
|
+
}),
|
|
511
|
+
});
|
|
512
|
+
```
|
|
513
|
+
|
|
128
514
|
### Custom Browser Auth Port
|
|
129
515
|
|
|
130
516
|
How a login is conducted — including which port the local OAuth2 callback
|
|
@@ -152,41 +538,118 @@ const token = await broker.getToken('TRIAL');
|
|
|
152
538
|
const newToken = await broker.refreshToken('TRIAL');
|
|
153
539
|
```
|
|
154
540
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
541
|
+
**Which provider answers.**
|
|
542
|
+
|
|
543
|
+
- **No `provider` option** — the destination's own: the very provider
|
|
544
|
+
`getProvider('TRIAL')` hands out, from the same per-destination cache. A
|
|
545
|
+
connector and the token API in one process therefore share one token, one
|
|
546
|
+
refresh token and one renewal: a `refreshToken()` while the connector renews
|
|
547
|
+
in `rejected()` joins that renewal, and a token the connector renewed is
|
|
548
|
+
what `getToken()` answers next. The destination must state a grant that
|
|
549
|
+
obtains a token — every grant but `none` (see the table under *A Provider
|
|
550
|
+
for a Connector*); a `none` destination is a `DestinationConfigError`
|
|
551
|
+
naming `provider`, before any provider is built.
|
|
552
|
+
- **A `provider` option** — yours, as in 3.x (see *Basic Usage*): the factory
|
|
553
|
+
is called once per destination (concurrent first calls build once; a call
|
|
554
|
+
after one that threw builds again), an instance serves every destination.
|
|
555
|
+
|
|
556
|
+
**What is written.** The session secret alone, in one `saveSession` — the
|
|
557
|
+
token, or the cookies of a SAML result (`tokenType: 'saml'`), its
|
|
558
|
+
`expiresAt`, the refresh token (the stored one carried forward when the
|
|
559
|
+
result has none), and what the secret is bound to (`issuedFor`, `issuedBy`,
|
|
560
|
+
see *A Secret Is Bound to Its Resource and Issuer*). Never `serviceUrl`,
|
|
561
|
+
`authType` or a client: they are means, and live in the key store.
|
|
562
|
+
|
|
563
|
+
- Without a `provider`, the token API writes nothing itself: the provider's
|
|
564
|
+
`onTokens` writes every token it obtains (see *Persistence and `flush()`*),
|
|
565
|
+
and a cache hit writes nothing.
|
|
566
|
+
- With a `provider`, the token API writes after every answer, cache hits
|
|
567
|
+
included, through the same write path. `issuedFor` is the `serviceUrl` it
|
|
568
|
+
resolved, with the SAP client; `issuedBy` the client a factory was handed —
|
|
569
|
+
an instance is handed none, so what it obtains is bound to no issuer and
|
|
570
|
+
`getProvider` never seeds from it. Both are fixed when your provider is
|
|
571
|
+
built for the destination (an instance: at its first call for it) and kept
|
|
572
|
+
with it: a URL or client changed later never re-labels a token that
|
|
573
|
+
provider obtained — a new broker picks the change up.
|
|
574
|
+
|
|
575
|
+
**A write that fails** reaches the caller: `getToken()` / `refreshToken()`
|
|
576
|
+
throw the store's error, as raised, for the token they received — and the
|
|
577
|
+
broker keeps retrying that write on its own; `flush()` tells you when it
|
|
578
|
+
landed.
|
|
579
|
+
|
|
580
|
+
**A destination stated `basic` or `snc` has no token API.** The token API
|
|
581
|
+
reads the destination's `authType` from the key store first and, for `basic`
|
|
582
|
+
or `snc`, throws `DestinationConfigError` naming `authType` before any provider
|
|
583
|
+
is asked — a token written over such a session would replace a credential
|
|
584
|
+
that is not one. A key store that states no `authType` (a 3.x setup), or no
|
|
585
|
+
key store, is served as before.
|
|
586
|
+
|
|
587
|
+
**Two token sources, with a `provider` option.** `getProvider` never uses
|
|
588
|
+
the `provider` option: the destination states its own provider. A process
|
|
589
|
+
that gives the broker a `provider` and also calls `getProvider` for the same
|
|
590
|
+
destination has two token sources for it — each with its own token and
|
|
591
|
+
renewal, both writing to the same session. Use one of them per destination;
|
|
592
|
+
or, to hand your own provider to a connector, wrap the token API:
|
|
593
|
+
`TokenAuthProvider.from(broker.createTokenRefresher('TRIAL'))`
|
|
594
|
+
(`@mcp-abap-adt/auth-providers`), whose every renewal the token API writes.
|
|
158
595
|
|
|
159
596
|
### Creating Token Refresher for DI
|
|
160
597
|
|
|
161
|
-
|
|
598
|
+
`createTokenRefresher(destination)` returns an `ITokenRefresher` (from
|
|
599
|
+
`@mcp-abap-adt/interfaces-auth`) bound to one destination — `getToken()` is
|
|
600
|
+
`broker.getToken(destination)`, `refreshToken()` is
|
|
601
|
+
`broker.refreshToken(destination)` — for a connection of your own that asks
|
|
602
|
+
for a token per request and for a new one after a 401. It is unchanged since
|
|
603
|
+
3.x and not deprecated: calm-server injects it into its own connection.
|
|
162
604
|
|
|
163
605
|
```typescript
|
|
164
|
-
import { AuthBroker } from '@mcp-abap-adt/auth-broker';
|
|
165
|
-
import { JwtAbapConnection } from '@mcp-abap-adt/connection';
|
|
166
|
-
|
|
167
|
-
// Create broker
|
|
168
|
-
const broker = new AuthBroker({
|
|
169
|
-
sessionStore: mySessionStore,
|
|
170
|
-
serviceKeyStore: myServiceKeyStore,
|
|
171
|
-
provider: myProviderFactory,
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// Create token refresher for specific destination
|
|
175
606
|
const tokenRefresher = broker.createTokenRefresher('TRIAL');
|
|
176
607
|
|
|
177
|
-
//
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
// - getToken(): the provider's current token (broker.getToken)
|
|
182
|
-
// - refreshToken(): a new token, never the cached one (broker.refreshToken)
|
|
608
|
+
// In your own connection:
|
|
609
|
+
const token = await tokenRefresher.getToken();
|
|
610
|
+
// …the server answered 401:
|
|
611
|
+
const newToken = await tokenRefresher.refreshToken();
|
|
183
612
|
```
|
|
184
613
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
614
|
+
A `@mcp-abap-adt/connection` 10 connector takes an `IAuthProvider` instead:
|
|
615
|
+
give it `await broker.getProvider('TRIAL')`.
|
|
616
|
+
|
|
617
|
+
## Migrating from 3.x
|
|
618
|
+
|
|
619
|
+
4.0.0 is a major: what a 3.x consumer must do, in short (the full list is the
|
|
620
|
+
[CHANGELOG](CHANGELOG.md)'s 4.0.0 entry).
|
|
621
|
+
|
|
622
|
+
1. **Session store: `@mcp-abap-adt/auth-stores` 3 or later** (`^3.1.0` for
|
|
623
|
+
the binding), or one of your own on the
|
|
624
|
+
`@mcp-abap-adt/interfaces-auth-broker` contract. The broker writes the
|
|
625
|
+
secret alone — token or cookies, `expiresAt`, refresh token, `issuedFor`,
|
|
626
|
+
`issuedBy` — and auth-stores 1.x/2.x's session stores refuse such a write
|
|
627
|
+
(their `AbapSessionStore` throws without `serviceUrl`): they are not
|
|
628
|
+
supported. A custom store must keep `issuedFor` and `issuedBy`.
|
|
629
|
+
2. **State the means in a key store**, not in the session: `authType`, and
|
|
630
|
+
for `jwt` / `saml` a `grantType`; the client; the URL. For a SAP service
|
|
631
|
+
key, `new AbapServiceKeyStore(dir, { grantType: 'authorization_code' })`
|
|
632
|
+
(or `XsuaaServiceKeyStore`); otherwise `EnvDestinationStore(dir, {
|
|
633
|
+
fallback })`. A 3.x session file is a readable destination as it is once
|
|
634
|
+
it states `SAP_AUTH_TYPE` (and `SAP_GRANT_TYPE` for `jwt` / `saml`).
|
|
635
|
+
3. **A connector of `@mcp-abap-adt/connection` 10** takes `await
|
|
636
|
+
broker.getProvider(destination)` — no `getToken` before connecting, no
|
|
637
|
+
token refresher. Give the broker the collaborators its grants need
|
|
638
|
+
(`authorization`, `oidcAuthorization`, `deviceCodePresenter`,
|
|
639
|
+
`samlCookies`, `assertionReplayStore`); there is no default.
|
|
640
|
+
4. **The token API** keeps its calls, signatures and 3.x reads with a
|
|
641
|
+
`provider` of yours; it writes the secret alone, refuses a destination
|
|
642
|
+
stated `basic` or `snc`, and throws a failed write while the broker retries
|
|
643
|
+
it.
|
|
644
|
+
5. **`getConnectionConfig` / `getAuthorizationConfig`** compose the key
|
|
645
|
+
store's means with the session's secret; a URL or client kept only in the
|
|
646
|
+
session store is not found any more.
|
|
647
|
+
6. **New:** catch `DestinationConfigError`; call `flush()` on shutdown;
|
|
648
|
+
write `bindingOf(means)` beside a credential you hand over yourself.
|
|
649
|
+
7. **Contracts:** `interfaces-auth` 3, `interfaces-auth-sap` 2,
|
|
650
|
+
`interfaces-auth-broker` 1.1 (the store contracts moved there from
|
|
651
|
+
`interfaces-auth-sap`), auth-providers 5. The commands are
|
|
652
|
+
`@mcp-abap-adt/auth-broker-cli` 2.0.0.
|
|
190
653
|
|
|
191
654
|
## Migrating from 2.2.0
|
|
192
655
|
|
|
@@ -208,117 +671,78 @@ const connection = new JwtAbapConnection(config, tokenRefresher);
|
|
|
208
671
|
it from the service key store if you relied on finding it in the session.
|
|
209
672
|
6. `refreshToken()` now forces a new token; it used to return `getToken()`'s.
|
|
210
673
|
7. Node.js 22, 24 or 26; SAML runs need the IdP trust (see *Migrating `mcp-sso`
|
|
211
|
-
SAML runs from 2.2.0*).
|
|
674
|
+
SAML runs from 2.2.0* in the [CLI's README](../auth-broker-cli/README.md)).
|
|
212
675
|
|
|
213
676
|
## Configuration
|
|
214
677
|
|
|
215
678
|
### Environment Variables
|
|
216
679
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
- `DEBUG_BROKER` - Enable debug logging for `auth-broker` package (short name)
|
|
224
|
-
- Set to `true` to enable logging (default: `false`)
|
|
225
|
-
- When enabled, logs authentication steps, token operations, and error details
|
|
226
|
-
- Can be explicitly disabled by setting to `false`
|
|
227
|
-
- Example: `DEBUG_BROKER=true npm test`
|
|
228
|
-
|
|
229
|
-
- `DEBUG_AUTH_BROKER` - Long name (backward compatibility)
|
|
230
|
-
- Same as `DEBUG_BROKER`, but longer name
|
|
231
|
-
- Example: `DEBUG_AUTH_BROKER=true npm test`
|
|
232
|
-
|
|
233
|
-
- `LOG_LEVEL` - Control log verbosity level
|
|
234
|
-
- Values: `debug`, `info`, `warn`, `error` (default: `info`)
|
|
235
|
-
- `debug` - All messages including detailed debug information
|
|
236
|
-
- `info` - Informational messages, warnings, and errors
|
|
237
|
-
- `warn` - Warnings and errors only
|
|
238
|
-
- `error` - Errors only
|
|
239
|
-
- Example: `LOG_LEVEL=debug DEBUG_BROKER=true npm test`
|
|
240
|
-
|
|
241
|
-
- `DEBUG` - Alternative way to enable debugging
|
|
242
|
-
- Set to `true` to enable all debug logging
|
|
243
|
-
- Or set to a string containing `broker` or `auth-broker` to enable only this package
|
|
244
|
-
- Example: `DEBUG=true npm test` or `DEBUG=broker npm test` or `DEBUG=auth-broker npm test`
|
|
245
|
-
|
|
246
|
-
**Note**: For debugging related packages:
|
|
247
|
-
- `DEBUG_STORES` (short) or `DEBUG_AUTH_STORES` (long) - Enable logging for `@mcp-abap-adt/auth-stores` package
|
|
248
|
-
- `DEBUG_PROVIDER` (short) or `DEBUG_AUTH_PROVIDERS` (long) - Enable logging for `@mcp-abap-adt/auth-providers` package
|
|
680
|
+
The library reads no environment variable: the stores take their directories
|
|
681
|
+
from their constructors, and the providers their settings from the
|
|
682
|
+
destination's means and the collaborators you give the broker. (In the
|
|
683
|
+
repository's test suites, `DEBUG_BROKER=true` — or `DEBUG_AUTH_BROKER=true`,
|
|
684
|
+
`DEBUG=broker` — turns on the test logger, its level from `AUTH_LOG_LEVEL`.)
|
|
249
685
|
|
|
250
|
-
|
|
686
|
+
### Logging
|
|
251
687
|
|
|
252
|
-
|
|
688
|
+
Pass an `ILogger` as the constructor's second argument (for instance
|
|
689
|
+
`DefaultLogger` from `@mcp-abap-adt/logger`); without one, nothing is logged.
|
|
253
690
|
|
|
254
|
-
|
|
691
|
+
**What is logged:** the broker's initialization (whether a key store is
|
|
692
|
+
given; the `provider` option: `none`, `factory` or `instance`), each provider build (the destination's `authType` and grant, whether
|
|
693
|
+
it was seeded), each session secret saved (token or cookies, whether a
|
|
694
|
+
refresh token came back, the expiry), a stored secret discarded because it is
|
|
695
|
+
bound elsewhere (the destination only), and each failed write with the error's
|
|
696
|
+
class name.
|
|
255
697
|
|
|
256
|
-
**What is logged:**
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
back, expiry). Store read failures are logged as warnings.
|
|
698
|
+
**What is never logged:** any part of a token, refresh token, password or
|
|
699
|
+
secret — not a prefix, not a suffix — nor a store error's message, which may
|
|
700
|
+
quote what was being written.
|
|
260
701
|
|
|
261
|
-
|
|
262
|
-
prefix, not a suffix. A log line says whether a token is there, never what it is.
|
|
702
|
+
With `DefaultLogger` at `info`:
|
|
263
703
|
|
|
264
|
-
Example output with `DEBUG_BROKER=true LOG_LEVEL=info`:
|
|
265
704
|
```
|
|
266
|
-
[INFO] ℹ️ [
|
|
705
|
+
[INFO] ℹ️ [AuthBroker] Session secret saved for TRIAL
|
|
706
|
+
{"credential":"token","hasRefreshToken":true,"expiresAt":1790000000000}
|
|
267
707
|
```
|
|
268
708
|
|
|
269
|
-
**Note**: Logging only works when a logger is explicitly provided to the broker constructor. The broker will not output anything to console if no logger is passed.
|
|
270
|
-
|
|
271
709
|
### File Structure
|
|
272
710
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
711
|
+
With `@mcp-abap-adt/auth-stores` 3 one `<destination>.env` file can hold a
|
|
712
|
+
destination whole, each store reading and writing only its own keys — the
|
|
713
|
+
means through `EnvDestinationStore`, the secret through `AbapSessionStore`
|
|
714
|
+
(or both in separate directories, as you compose them):
|
|
276
715
|
|
|
277
716
|
```env
|
|
717
|
+
# The means — EnvDestinationStore (ABAP_DESTINATION_VARS); the broker never writes them
|
|
278
718
|
SAP_URL=https://your-system.abap.us10.hana.ondemand.com
|
|
279
719
|
SAP_CLIENT=100
|
|
280
|
-
|
|
281
|
-
|
|
720
|
+
SAP_AUTH_TYPE=jwt
|
|
721
|
+
SAP_GRANT_TYPE=authorization_code
|
|
282
722
|
SAP_UAA_URL=https://your-account.authentication.us10.hana.ondemand.com
|
|
283
723
|
SAP_UAA_CLIENT_ID=client_id
|
|
284
724
|
SAP_UAA_CLIENT_SECRET=client_secret
|
|
285
|
-
```
|
|
286
725
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
XSUAA_JWT_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
294
|
-
XSUAA_REFRESH_TOKEN=refresh_token_string
|
|
295
|
-
XSUAA_UAA_URL=https://your-account.authentication.eu10.hana.ondemand.com
|
|
296
|
-
XSUAA_UAA_CLIENT_ID=client_id
|
|
297
|
-
XSUAA_UAA_CLIENT_SECRET=client_secret
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
**Note**: `XSUAA_MCP_URL` is optional - it's not part of authentication, only needed for making requests. The token and UAA credentials are sufficient for authentication.
|
|
301
|
-
|
|
302
|
-
#### Environment File for BTP (`{destination}.env`)
|
|
303
|
-
|
|
304
|
-
For BTP connections (full scope for ABAP systems), use `BTP_*` environment variables:
|
|
305
|
-
|
|
306
|
-
```env
|
|
307
|
-
BTP_ABAP_URL=https://your-system.abap.us10.hana.ondemand.com
|
|
308
|
-
BTP_JWT_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
309
|
-
BTP_REFRESH_TOKEN=refresh_token_string
|
|
310
|
-
BTP_UAA_URL=https://your-account.authentication.eu10.hana.ondemand.com
|
|
311
|
-
BTP_UAA_CLIENT_ID=client_id
|
|
312
|
-
BTP_UAA_CLIENT_SECRET=client_secret
|
|
313
|
-
BTP_SAP_CLIENT=100
|
|
314
|
-
BTP_LANGUAGE=EN
|
|
726
|
+
# The secret — AbapSessionStore (ABAP_SESSION_VARS); what the broker writes
|
|
727
|
+
SAP_JWT_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
728
|
+
SAP_EXPIRES_AT=1790000000000
|
|
729
|
+
SAP_REFRESH_TOKEN=refresh_token_string
|
|
730
|
+
SAP_ISSUED_FOR=https://your-system.abap.us10.hana.ondemand.com:443?sap-client=100
|
|
731
|
+
SAP_ISSUED_BY=https://your-account.authentication.us10.hana.ondemand.com:443?client_id=client_id
|
|
315
732
|
```
|
|
316
733
|
|
|
317
|
-
|
|
734
|
+
SAML cookies are `SAP_SESSION_COOKIES_B64` in place of `SAP_JWT_TOKEN`. The
|
|
735
|
+
XSUAA stores use the same names with `XSUAA_` (`XSUAA_DESTINATION_VARS`,
|
|
736
|
+
`XSUAA_SESSION_VARS`; the URL is `XSUAA_MCP_URL`). Every means key
|
|
737
|
+
(`SAP_USERNAME`, `SAP_SNC_*`, `SAP_OIDC_*`, `SAP_SAML_*`, …) is listed in
|
|
738
|
+
[USAGE.md](../../docs/using/USAGE.md#environment-variables).
|
|
318
739
|
|
|
319
|
-
#### Service Key File
|
|
740
|
+
#### Service Key File (`{destination}.json`)
|
|
320
741
|
|
|
321
|
-
|
|
742
|
+
A SAP service key, read by `AbapServiceKeyStore` (an ABAP environment key:
|
|
743
|
+
the client and the ABAP URL) or `XsuaaServiceKeyStore` (an XSUAA key: the
|
|
744
|
+
client). A key cannot state which grant the destination uses, so the store is
|
|
745
|
+
told: `new AbapServiceKeyStore(dir, { grantType: 'authorization_code' })`.
|
|
322
746
|
|
|
323
747
|
```json
|
|
324
748
|
{
|
|
@@ -331,40 +755,9 @@ Standard ABAP service key format:
|
|
|
331
755
|
}
|
|
332
756
|
```
|
|
333
757
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
```json
|
|
339
|
-
{
|
|
340
|
-
"url": "https://your-account.authentication.eu10.hana.ondemand.com",
|
|
341
|
-
"apiurl": "https://api.authentication.eu10.hana.ondemand.com",
|
|
342
|
-
"clientid": "your_client_id",
|
|
343
|
-
"clientsecret": "your_client_secret"
|
|
344
|
-
}
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
**Note**: For XSUAA service keys, `apiurl` is prioritized over `url` for UAA authorization if present.
|
|
348
|
-
|
|
349
|
-
## XSUAA vs BTP Authentication
|
|
350
|
-
|
|
351
|
-
This package supports two types of BTP authentication:
|
|
352
|
-
|
|
353
|
-
### XSUAA (Reduced Scope)
|
|
354
|
-
- **Purpose**: Access BTP services with limited scopes
|
|
355
|
-
- **Service Key**: Contains only UAA credentials (no ABAP URL)
|
|
356
|
-
- **Session Store**: `XsuaaSessionStore` (uses `XSUAA_*` environment variables)
|
|
357
|
-
- **Authentication**: Client credentials grant type (no browser required)
|
|
358
|
-
- **MCP URL**: Optional, provided separately (from YAML config `mcp_url`, parameter, or request header)
|
|
359
|
-
- **Use Case**: Accessing BTP services like MCP servers with reduced permissions
|
|
360
|
-
|
|
361
|
-
### BTP (Full Scope for ABAP)
|
|
362
|
-
- **Purpose**: Access ABAP systems with full roles and scopes
|
|
363
|
-
- **Service Key**: Contains UAA credentials and ABAP URL
|
|
364
|
-
- **Session Store**: `BtpSessionStore` (uses `BTP_*` environment variables)
|
|
365
|
-
- **Authentication**: Browser-based OAuth2 (like ABAP) or refresh token
|
|
366
|
-
- **ABAP URL**: Required, from service key or YAML configuration
|
|
367
|
-
- **Use Case**: Accessing ABAP systems in BTP with full permissions
|
|
758
|
+
An XSUAA key carries no URL of the resource it authorizes for: state it as
|
|
759
|
+
means in an `EnvDestinationStore` (`XSUAA_MCP_URL`, with
|
|
760
|
+
`XSUAA_DESTINATION_VARS`) whose `fallback` is the `XsuaaServiceKeyStore`.
|
|
368
761
|
|
|
369
762
|
## Responsibilities and Design Principles
|
|
370
763
|
|
|
@@ -373,7 +766,7 @@ This package supports two types of BTP authentication:
|
|
|
373
766
|
**Interface-Only Communication**: This package follows a fundamental development principle: **all interactions with external dependencies happen ONLY through interfaces**. The code knows **NOTHING beyond what is defined in the interfaces**.
|
|
374
767
|
|
|
375
768
|
This means:
|
|
376
|
-
- Does not know about concrete
|
|
769
|
+
- Does not know about concrete store classes (e.g., `AbapSessionStore`); the provider classes it knows only to construct the one a destination states, and hands out as `IAuthProvider`
|
|
377
770
|
- Does not know about internal data structures or methods not defined in interfaces
|
|
378
771
|
- Does not make assumptions about implementation behavior beyond interface contracts
|
|
379
772
|
- Does not access properties or methods not explicitly defined in interfaces
|
|
@@ -390,11 +783,11 @@ The `@mcp-abap-adt/auth-broker` package defines **interfaces** and provides **or
|
|
|
390
783
|
|
|
391
784
|
#### What AuthBroker Does
|
|
392
785
|
|
|
393
|
-
- **Resolves what the stores hold**: the
|
|
394
|
-
- **Builds or reuses the provider**:
|
|
786
|
+
- **Resolves what the stores hold**: the means from the key store, the secret from the session store (and, for a `provider` of yours, the 3.x reads: the service URL and the UAA credentials, session first)
|
|
787
|
+
- **Builds or reuses the provider**: one per destination — the one the destination states, shared by `getProvider` and the token API; or your factory, called once per destination
|
|
395
788
|
- **Asks the provider once**: `getTokens()` for `getToken()`, `refreshTokens()` for `refreshToken()` — no retries, no fallbacks
|
|
396
|
-
- **Persists the answer**: token or session cookies,
|
|
397
|
-
- **Works with interfaces only**: `IServiceKeyStore`, `ISessionStore`, `IRefreshableTokenProvider`
|
|
789
|
+
- **Persists the answer**: the session secret alone — token or session cookies, `expiresAt`, the refresh token, and what it is bound to — to `sessionStore`, retrying a write that fails
|
|
790
|
+
- **Works with interfaces only**: `IServiceKeyStore`, `ISessionStore`, `IAuthProvider`, `IRefreshableTokenProvider`
|
|
398
791
|
|
|
399
792
|
#### What AuthBroker Does NOT Do
|
|
400
793
|
|
|
@@ -408,19 +801,15 @@ The `@mcp-abap-adt/auth-broker` package defines **interfaces** and provides **or
|
|
|
408
801
|
|
|
409
802
|
The **consumer** (application using `AuthBroker`) is responsible for:
|
|
410
803
|
|
|
411
|
-
1. **
|
|
412
|
-
- **ABAP systems**:
|
|
413
|
-
- **
|
|
414
|
-
- **XSUAA services**:
|
|
804
|
+
1. **Composing the stores and collaborators**: an `IServiceKeyStore` for the means and an `ISessionStore` for the secret — with auth-stores 3:
|
|
805
|
+
- **ABAP systems (a SAP service key)**: `AbapServiceKeyStore(dir, { grantType })`, alone or as the `fallback` of an `EnvDestinationStore`, and `AbapSessionStore` (or `SafeAbapSessionStore`, in memory)
|
|
806
|
+
- **Destinations without a key** (basic, SNC, OIDC, SAML, `none`): `EnvDestinationStore(dir)` and `AbapSessionStore`
|
|
807
|
+
- **XSUAA services**: `EnvDestinationStore(dir, { variables: XSUAA_DESTINATION_VARS, fallback: new XsuaaServiceKeyStore(keys, { grantType }) })` and `XsuaaSessionStore` (or `SafeXsuaaSessionStore`)
|
|
808
|
+
- the collaborator options the destinations' grants need — or, for the token API the 3.x way, an `IRefreshableTokenProvider` of your own
|
|
415
809
|
|
|
416
|
-
2. **
|
|
417
|
-
- The session is created with `serviceUrl` before calling `AuthBroker.getToken()`, OR
|
|
418
|
-
- The session store implementation handles `serviceUrl` retrieval internally (e.g., from `serviceKeyStore`)
|
|
810
|
+
2. **Stating the means in the key store**: `authType`, `grantType` and the fields its row reads, the client, and `serviceUrl` — for the token API with a `provider` of yours, the service URL comes from the session, else the key store.
|
|
419
811
|
|
|
420
|
-
3. **
|
|
421
|
-
- `AbapSessionStore`: Requires `sapUrl` (maps to `serviceUrl` in `IConnectionConfig`)
|
|
422
|
-
- `BtpSessionStore`: Does not require `serviceUrl` (uses `mcpUrl` instead)
|
|
423
|
-
- `XsuaaSessionStore`: Does not require `serviceUrl` (MCP URL is optional)
|
|
812
|
+
3. **A session store that takes the secret alone**: the broker writes the token or cookies, `expiresAt`, the refresh token, `issuedFor` and `issuedBy` in one `saveSession`, and nothing else. auth-stores 3's session stores do; auth-stores 1.x/2.x's `AbapSessionStore` and `SafeAbapSessionStore` refuse a write without `serviceUrl` (measured on 1.2.3, 1.2.4, 2.0.0), so a token API on them throws after every token. A custom store must also keep `issuedFor` and `issuedBy`.
|
|
424
813
|
|
|
425
814
|
### Store Responsibilities
|
|
426
815
|
|
|
@@ -428,10 +817,7 @@ Concrete `ISessionStore` implementations are responsible for:
|
|
|
428
817
|
|
|
429
818
|
- **Handling their own data format**: Each store knows its internal data format (e.g., `AbapSessionData`, `BtpBaseSessionData`)
|
|
430
819
|
- **Converting between formats**: Converting between `IConfig`/`IConnectionConfig` and internal storage format
|
|
431
|
-
- **
|
|
432
|
-
- Retrieve it from `serviceKeyStore` if not provided in `IConnectionConfig`, OR
|
|
433
|
-
- Use existing value from current session if available, OR
|
|
434
|
-
- Throw an error if neither is available (depending on implementation)
|
|
820
|
+
- **Taking the secret alone**: a session store is written the secret and its binding only, and keeps what it held beside them (auth-stores 3 refuses means outright)
|
|
435
821
|
|
|
436
822
|
### Provider Responsibilities
|
|
437
823
|
|
|
@@ -445,7 +831,7 @@ Concrete `IRefreshableTokenProvider` implementations are responsible for:
|
|
|
445
831
|
### Design Principles
|
|
446
832
|
|
|
447
833
|
1. **Interface-Only Communication** (Core Principle): All interactions with external dependencies happen **ONLY through interfaces**. The code knows **NOTHING beyond what is defined in the interfaces** (see [Core Development Principle](#core-development-principle) above)
|
|
448
|
-
2. **Dependency Inversion Principle (DIP)**: `AuthBroker` depends on abstractions (`IServiceKeyStore`, `ISessionStore`, `IRefreshableTokenProvider`), not concrete implementations
|
|
834
|
+
2. **Dependency Inversion Principle (DIP)**: `AuthBroker` depends on abstractions (`IServiceKeyStore`, `ISessionStore`, `IAuthProvider`, `IRefreshableTokenProvider`), not concrete store implementations; the providers it builds it hands out as `IAuthProvider`
|
|
449
835
|
3. **Single Responsibility**: Each component has a single, well-defined responsibility:
|
|
450
836
|
- `AuthBroker`: Orchestration — resolving, asking, persisting
|
|
451
837
|
- `ISessionStore`: Session data storage and retrieval
|
|
@@ -464,54 +850,134 @@ Concrete `IRefreshableTokenProvider` implementations are responsible for:
|
|
|
464
850
|
new AuthBroker(
|
|
465
851
|
config: {
|
|
466
852
|
sessionStore: ISessionStore; // required
|
|
467
|
-
serviceKeyStore?: IServiceKeyStore; //
|
|
468
|
-
provider
|
|
853
|
+
serviceKeyStore?: IServiceKeyStore; // getProvider needs it
|
|
854
|
+
provider?: // the token API's, optional
|
|
469
855
|
| IRefreshableTokenProvider
|
|
470
856
|
| ((
|
|
471
857
|
destination: string,
|
|
472
858
|
authConfig: IAuthorizationConfig | null,
|
|
473
859
|
connConfig: IConnectionConfig,
|
|
474
860
|
) => IRefreshableTokenProvider);
|
|
861
|
+
// Collaborators, each a function of the destination, called once per
|
|
862
|
+
// build, never disposed by the broker:
|
|
863
|
+
authorization?: (destination: string, grant: StrategyGrant) => IAuthorizationStrategy<string>; // authorization_code, passcode, saml2_pure, saml2_bearer
|
|
864
|
+
oidcAuthorization?: (destination: string) => IAuthorizationStrategy<OidcCallbackResult>; // oidc_authorization_code
|
|
865
|
+
deviceCodePresenter?: (destination: string) => IDeviceCodePresenter; // device_code
|
|
866
|
+
samlCookies?: (destination: string) => (samlResponse: string) => Promise<string>; // saml2_pure
|
|
867
|
+
assertionReplayStore?: (destination: string) => IAssertionReplayStore; // saml2_pure, saml2_bearer
|
|
475
868
|
},
|
|
476
869
|
logger?: ILogger,
|
|
477
870
|
)
|
|
478
871
|
```
|
|
479
872
|
|
|
480
873
|
**Parameters:**
|
|
481
|
-
- `config.sessionStore` - **Required** -
|
|
482
|
-
- `config.serviceKeyStore` -
|
|
483
|
-
- `config.provider` -
|
|
874
|
+
- `config.sessionStore` - **Required** - The session secret: the token or cookies, `expiresAt`, the refresh token, and what it is bound to (`issuedFor`, `issuedBy`). For the token API with a `provider`, its `serviceUrl`, or the service key's, is required (3.x's reads).
|
|
875
|
+
- `config.serviceKeyStore` - The means: `authType`, `grantType`, the client, basic's user and password, the SNC, OIDC and SAML fields, `serviceUrl`. **Required by `getProvider`**, which has no other source of means.
|
|
876
|
+
- `config.provider` - The token API's source (`getToken`, `refreshToken`, `createTokenRefresher`): a provider instance, used for every destination, or a factory (`TokenProviderFactory`), called once per destination and seeded with what the stores hold (see *Basic Usage*). Not used by `getProvider`. Without it, the token API asks the provider `getProvider` builds for the destination (see *Getting Tokens*); with neither it nor a `serviceKeyStore`, the token API throws `DestinationConfigError` naming both.
|
|
877
|
+
- `config.authorization` - The interactive strategy of `jwt` / `authorization_code`, `jwt` / `passcode`, `saml` / `saml2_pure` and `saml` / `saml2_bearer`, as a function of the destination and the grant (see *A Provider for a Connector*).
|
|
878
|
+
- `config.oidcAuthorization` - The interactive strategy of `jwt` / `oidc_authorization_code`.
|
|
879
|
+
- `config.deviceCodePresenter` - Where `jwt` / `device_code` shows the user the verification URL and code.
|
|
880
|
+
- `config.samlCookies` - `saml2_pure`: turns the validated SAMLResponse into the system's session cookies.
|
|
881
|
+
- `config.assertionReplayStore` - The replay store the SAML validators record each assertion in.
|
|
882
|
+
- Each collaborator option is required only by the rows that use it (missing → `DestinationConfigError` naming it); the broker supplies no default, calls it once per build, and disposes nothing it returns.
|
|
484
883
|
- `logger` - Optional logger. If not provided, nothing is logged.
|
|
485
884
|
|
|
486
885
|
**Available Implementations:**
|
|
487
|
-
- **
|
|
488
|
-
- **
|
|
886
|
+
- **Means** (auth-stores 3): `EnvDestinationStore(directory, { fallback? })`, `AbapServiceKeyStore(directory, { grantType?, log? })`, `XsuaaServiceKeyStore(directory, { grantType?, log? })`
|
|
887
|
+
- **Secret** (auth-stores 3): `AbapSessionStore(directory, log?)`, `SafeAbapSessionStore(log?)`, `XsuaaSessionStore(directory, log?)`, `SafeXsuaaSessionStore(log?)`, `EnvFileSessionStore(file, log?)`
|
|
888
|
+
- **Providers** (auth-providers 5): `AuthorizationCodeProvider(...)`, `ClientCredentialsProvider(...)` and the rest, for a `provider` of yours
|
|
489
889
|
|
|
490
890
|
#### Methods
|
|
491
891
|
|
|
892
|
+
##### `getProvider(destination: string): Promise<IAuthProvider>`
|
|
893
|
+
|
|
894
|
+
The `IAuthProvider` the destination states, built from the key store's means
|
|
895
|
+
and the session store's secret (see *A Provider for a Connector*). Cached per
|
|
896
|
+
destination; a failed build is retried on the next call. Throws
|
|
897
|
+
`DestinationConfigError` for a destination that lacks what its type needs, and
|
|
898
|
+
passes a store failure other than absence on as the store raised it.
|
|
899
|
+
|
|
900
|
+
##### `flush(): Promise<void>`
|
|
901
|
+
|
|
902
|
+
Every session write still pending gets one more attempt. Resolves when all
|
|
903
|
+
have landed; rejects with an `AggregateError` naming the destinations whose
|
|
904
|
+
store still refuses (its `errors` carry each destination and the store
|
|
905
|
+
error's class only, never its message) — the broker keeps retrying them. See *Persistence and `flush()`*.
|
|
906
|
+
|
|
492
907
|
##### `getToken(destination: string): Promise<string>`
|
|
493
908
|
|
|
494
|
-
1.
|
|
495
|
-
2.
|
|
496
|
-
3.
|
|
497
|
-
4.
|
|
498
|
-
5. Returns the token.
|
|
909
|
+
1. Reads the destination's `authType` from the key store: `basic` or `snc` is a `DestinationConfigError` naming `authType`, before any provider is asked.
|
|
910
|
+
2. **Without a `provider` option:** takes the provider `getProvider(destination)` hands out (the same cache; a `none` destination is a `DestinationConfigError` naming `provider`), calls `getTokens()` once, and writes nothing itself — the provider's `onTokens` wrote anything new.
|
|
911
|
+
3. **With one:** resolves `serviceUrl` (session, else service key; an error if neither has one), builds the provider on first use (factory form) seeded with the credentials, the stored refresh token and the stored token — or uses the instance — calls `getTokens()` once, and writes the result: the secret alone, through the broker's write path.
|
|
912
|
+
4. Throws the store's error if the write of this token failed (the broker keeps retrying it); else returns the token.
|
|
499
913
|
|
|
500
914
|
##### `refreshToken(destination: string): Promise<string>`
|
|
501
915
|
|
|
502
916
|
The same, with `provider.refreshTokens()`: a new token, never the cached one —
|
|
503
|
-
for a caller whose token the server has just refused.
|
|
917
|
+
for a caller whose token the server has just refused. A renewal already in
|
|
918
|
+
flight for the destination (a connector's, in `rejected()`) is joined.
|
|
504
919
|
|
|
505
920
|
##### `getAuthorizationConfig(destination)` / `getConnectionConfig(destination)`
|
|
506
921
|
|
|
507
|
-
The
|
|
922
|
+
The two stores composed, each for its role:
|
|
923
|
+
|
|
924
|
+
- `getConnectionConfig` — the service key store's means with the session's
|
|
925
|
+
secret (`authorizationToken`, `sessionCookies`, `expiresAt`) laid over them;
|
|
926
|
+
`null` when neither store holds anything.
|
|
927
|
+
- `getAuthorizationConfig` — the service key store's client (`uaaUrl`,
|
|
928
|
+
`uaaClientId`, `uaaClientSecret`) with the session's refresh token; `null`
|
|
929
|
+
when the key store has no client.
|
|
930
|
+
|
|
931
|
+
Means a session store answers (a client, a URL, an `authType`) are not read,
|
|
932
|
+
nor a secret a key store answers. Up to 3.1.0 both answered the session's
|
|
933
|
+
configuration whole, and the key's only when the session had none.
|
|
508
934
|
|
|
509
935
|
##### `createTokenRefresher(destination): ITokenRefresher`
|
|
510
936
|
|
|
511
|
-
`getToken()` and `refreshToken()` bound to one destination, for injection into a connection.
|
|
937
|
+
`getToken()` and `refreshToken()` bound to one destination, for injection into a connection of your own. Unchanged since 3.x; not deprecated.
|
|
512
938
|
|
|
513
939
|
##### Error Handling
|
|
514
940
|
|
|
941
|
+
- **`DestinationConfigError`** — a destination that lacks what its type needs.
|
|
942
|
+
`getProvider` throws it before any provider is asked; so does the token
|
|
943
|
+
API, for the same destinations and for those below. It carries `code:
|
|
944
|
+
'DESTINATION_CONFIG'`, `destination`, `missingFields` — field or option
|
|
945
|
+
names only. When a provider's constructor refused (an `sncQop` outside `1`,
|
|
946
|
+
`2`, `3`, `8`, `9`), the store field is named and the provider's own error
|
|
947
|
+
is not kept — its message quotes the value. **No stored value
|
|
948
|
+
reaches it**: not a password, not a token, not an `authType` that is none of
|
|
949
|
+
the four. Thrown for:
|
|
950
|
+
|
|
951
|
+
| Case | `missingFields` |
|
|
952
|
+
|---|---|
|
|
953
|
+
| no `serviceKeyStore` option (`getProvider`) | `serviceKeyStore` |
|
|
954
|
+
| the token API with neither a `provider` nor a `serviceKeyStore` option | `provider`, `serviceKeyStore` |
|
|
955
|
+
| the token API on a destination stated `basic` or `snc` | `authType` |
|
|
956
|
+
| the token API without a `provider` option, on a `jwt` / `none` or `saml` / `none` destination | `provider` |
|
|
957
|
+
| the key store has no means for the destination — whatever the session holds | `authType` |
|
|
958
|
+
| no `authType`, `''`, or one that is not `basic`, `jwt`, `saml`, `snc` | `authType` |
|
|
959
|
+
| `jwt` / `saml` without `grantType`, `''`, or a pair outside the table | `grantType` |
|
|
960
|
+
| `basic` without user or password (`''` counts as missing) | `username`, `password` — each that is missing |
|
|
961
|
+
| `snc` without `sncPartnerName` | `sncPartnerName` |
|
|
962
|
+
| `snc` whose settings the provider refuses | the store field, e.g. `sncQop` |
|
|
963
|
+
| `jwt` / `none` without a token in the session | `authorizationToken` |
|
|
964
|
+
| `saml` / `none` without cookies in the session | `sessionCookies` |
|
|
965
|
+
| `jwt` / `none`, `saml` / `none` whose stored `issuedFor` is not the destination's resource, or is absent, or the means state no `serviceUrl` | `issuedFor` |
|
|
966
|
+
| `jwt` / `none`, `saml` / `none` whose means state an issuer and whose stored `issuedBy` is not it, or is absent | `issuedBy` |
|
|
967
|
+
| a UAA grant without its client in the key store (`''` counts as missing) | `uaaUrl`, `uaaClientId`, and `uaaClientSecret` for `authorization_code` / `client_credentials` — each that is missing |
|
|
968
|
+
| `authorization_code` / `passcode` without the `authorization` option | `authorization` |
|
|
969
|
+
| an OIDC grant without its client's id in the key store | `uaaClientId` |
|
|
970
|
+
| an OIDC grant with neither `oidcIssuerUrl` nor every endpoint its row reads | `oidcIssuerUrl`, and each endpoint missing: `oidcAuthorizationEndpoint` / `oidcDeviceAuthorizationEndpoint`, `oidcTokenEndpoint` |
|
|
971
|
+
| `password` without user or password | `username`, `password` — each that is missing |
|
|
972
|
+
| `token_exchange` without its subject | `oidcSubjectToken`, `oidcSubjectTokenType` — each that is missing |
|
|
973
|
+
| `oidc_authorization_code` without the `oidcAuthorization` option | `oidcAuthorization` |
|
|
974
|
+
| `device_code` without the `deviceCodePresenter` option | `deviceCodePresenter` |
|
|
975
|
+
| a SAML grant without its trust (`''` and an empty certificate list count as missing) | `samlIdpSsoUrl`, `samlSpEntityId`, `samlIdpEntityId`, `samlIdpCertificates` — each that is missing |
|
|
976
|
+
| `saml2_bearer` without its client | `uaaUrl`, `uaaClientId` — each that is missing |
|
|
977
|
+
| a SAML grant without its collaborators | `authorization`, `samlCookies` (`saml2_pure`), `assertionReplayStore` — each that is missing |
|
|
978
|
+
| a certificate the validator cannot read | `samlIdpCertificates` |
|
|
979
|
+
| a `samlClockSkewMs` that is not a whole, non-negative number | `samlClockSkewMs` |
|
|
980
|
+
|
|
515
981
|
- **Provider errors propagate unchanged** — the same object, with its class,
|
|
516
982
|
`code`, `missingFields` and `cause`: auth-providers' `ValidationError`,
|
|
517
983
|
`BrowserAuthError`, `AssertionValidationError`, network errors (`ECONNREFUSED`,
|
|
@@ -528,12 +994,25 @@ The session's configuration, else the service key's, else `null`.
|
|
|
528
994
|
the new token was written. (The session stores of
|
|
529
995
|
auth-stores answer an unreadable session file with `null` themselves, so it
|
|
530
996
|
reads as absent before the broker sees it.)
|
|
531
|
-
- **Store writes
|
|
997
|
+
- **Store writes**: a write that fails reaches the token API's caller — the
|
|
998
|
+
store's error, as raised, for the token it received — and is retried by the
|
|
999
|
+
broker all the same. A `getProvider` provider's write never fails the
|
|
1000
|
+
authentication; it is retried, and `flush()` reports it.
|
|
532
1001
|
- **A provider result without a token** is an error.
|
|
533
1002
|
|
|
534
1003
|
```typescript
|
|
1004
|
+
import { DestinationConfigError } from '@mcp-abap-adt/auth-broker';
|
|
535
1005
|
import { ValidationError } from '@mcp-abap-adt/auth-providers';
|
|
536
1006
|
|
|
1007
|
+
try {
|
|
1008
|
+
const provider = await broker.getProvider('TRIAL');
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
if (error instanceof DestinationConfigError) {
|
|
1011
|
+
logger.error(`${error.destination} lacks: ${error.missingFields.join(', ')}`);
|
|
1012
|
+
}
|
|
1013
|
+
throw error;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
537
1016
|
try {
|
|
538
1017
|
const token = await broker.getToken('TRIAL');
|
|
539
1018
|
} catch (error) {
|
|
@@ -546,38 +1025,32 @@ try {
|
|
|
546
1025
|
|
|
547
1026
|
#### Secrets in the Session Store
|
|
548
1027
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
1028
|
+
Everything the broker writes — a `getProvider` provider's token and the
|
|
1029
|
+
token API's alike — is the secret alone: `authorizationToken` (or
|
|
1030
|
+
`sessionCookies`), `expiresAt`, `refreshToken`, with `issuedFor` and
|
|
1031
|
+
`issuedBy`, in one `saveSession`, and nothing else (see *Persistence and
|
|
1032
|
+
`flush()`*). Never the client secret, never `serviceUrl` or `authType`:
|
|
1033
|
+
`setConnectionConfig` and `setAuthorizationConfig` are not called. A store
|
|
1034
|
+
that merges a write into what it holds keeps a client or URL it held beside
|
|
1035
|
+
the secret; auth-stores 3 holds the secret alone.
|
|
554
1036
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
process's provider — the XSUAA stores too, from auth-stores 1.2.3.
|
|
1037
|
+
The stored refresh token comes back through `loadSession()`, which the broker
|
|
1038
|
+
reads to seed the next process's provider.
|
|
558
1039
|
|
|
559
1040
|
### Token Providers
|
|
560
1041
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
- No browser interaction required
|
|
566
|
-
- No refresh token provided
|
|
567
|
-
|
|
568
|
-
- **`AuthorizationCodeProvider`** - For BTP/ABAP authentication (full scope)
|
|
569
|
-
- How the login is conducted is an `authorization?: IAuthorizationStrategy<string>`, not a
|
|
570
|
-
provider field. Omitted, it defaults to a browser callback on port `61001`
|
|
571
|
-
- `browserCallbackStrategy({ browser?, port?, timeoutMs? })` from `@mcp-abap-adt/auth-providers`
|
|
572
|
-
builds the ready-made strategy; pass `port` to avoid conflicts when running alongside other
|
|
573
|
-
services (e.g. a proxy server) or to match a redirect URI registered at the identity provider
|
|
574
|
-
- The callback port is held only for the duration of a login and released when it ends —
|
|
575
|
-
by success, failure, timeout, or cancellation
|
|
576
|
-
- Uses browser-based OAuth2 flow (if no refresh token)
|
|
577
|
-
- Uses refresh token if available
|
|
578
|
-
- Provides refresh token for future use
|
|
1042
|
+
Provider implementations live in `@mcp-abap-adt/auth-providers` 5. `getProvider`
|
|
1043
|
+
builds the one the destination states (see *A Provider for a Connector*); for
|
|
1044
|
+
the token API the 3.x way you pass one yourself — an `IRefreshableTokenProvider`
|
|
1045
|
+
or a factory:
|
|
579
1046
|
|
|
580
|
-
|
|
1047
|
+
- **`ClientCredentialsProvider`** — `client_credentials`: no user, no browser,
|
|
1048
|
+
no refresh token.
|
|
1049
|
+
- **`AuthorizationCodeProvider`** — `authorization_code`: refreshes by the
|
|
1050
|
+
stored refresh token, and logs in through the `authorization` strategy you
|
|
1051
|
+
give it — there is no default. `browserCallbackStrategy({ browser?, port?,
|
|
1052
|
+
timeoutMs? })` builds the usual one (callback port `61001` unless `port` is
|
|
1053
|
+
given; held only for the duration of a login).
|
|
581
1054
|
|
|
582
1055
|
```typescript
|
|
583
1056
|
import { AuthBroker } from '@mcp-abap-adt/auth-broker';
|
|
@@ -589,7 +1062,7 @@ import { ClientCredentialsProvider } from '@mcp-abap-adt/auth-providers';
|
|
|
589
1062
|
|
|
590
1063
|
// XSUAA, client_credentials: credentials from the service key
|
|
591
1064
|
const xsuaaBroker = new AuthBroker({
|
|
592
|
-
sessionStore: new XsuaaSessionStore('/path/to/sessions'
|
|
1065
|
+
sessionStore: new XsuaaSessionStore('/path/to/sessions'),
|
|
593
1066
|
serviceKeyStore: new XsuaaServiceKeyStore('/path/to/keys'),
|
|
594
1067
|
provider: (destination, authConfig) => {
|
|
595
1068
|
if (!authConfig) throw new Error(`No UAA credentials for ${destination}`);
|
|
@@ -602,339 +1075,56 @@ const xsuaaBroker = new AuthBroker({
|
|
|
602
1075
|
});
|
|
603
1076
|
```
|
|
604
1077
|
|
|
605
|
-
### CLI: mcp-auth
|
|
606
|
-
|
|
607
|
-
Generate or refresh `.env`/JSON output using AuthBroker + stores:
|
|
608
|
-
|
|
609
|
-
```bash
|
|
610
|
-
mcp-auth <auth-code|oidc|saml2-pure|saml2-bearer> [options]
|
|
611
|
-
mcp-auth --service-key <path> --output <path> [--env <path>] [--type abap|xsuaa] [--credential] [--browser auto|none|system|chrome|edge|firefox] [--format json|env]
|
|
612
|
-
```
|
|
613
|
-
|
|
614
|
-
**Note**: The published CLI is compiled to `dist/bin` and does not require `tsx` at runtime. For repo usage, run `npm install` and `npm run build`.
|
|
615
|
-
|
|
616
|
-
**Authentication Flow:**
|
|
617
|
-
- Default: `authorization_code` (browser-based OAuth2)
|
|
618
|
-
- `--credential`: `client_credentials` (clientId/clientSecret, no browser)
|
|
619
|
-
|
|
620
|
-
**Browser Options (for authorization_code):**
|
|
621
|
-
- `auto` (default): Try to open browser, fallback to showing URL
|
|
622
|
-
- `none`: Show URL in console and wait for callback (no browser)
|
|
623
|
-
- `system/chrome/edge/firefox`: Open specific browser
|
|
624
|
-
|
|
625
|
-
`--browser` and `--redirect-port` are routed into `browserCallbackStrategy`. This CLI has no
|
|
626
|
-
default of its own for the callback port: `--redirect-port` overrides it when given; omitted,
|
|
627
|
-
the port comes from `auth-providers` (currently `61001`, chosen to sit above the ephemeral range
|
|
628
|
-
and clear of the `3001`/`3333` range servers and proxies typically use). If you registered a
|
|
629
|
-
redirect URI with a specific port at your identity provider, pass `--redirect-port` to match it.
|
|
630
|
-
A login is given 5 minutes to complete (this is a person switching to a browser and signing in
|
|
631
|
-
by hand, not an unattended caller).
|
|
632
|
-
|
|
633
|
-
**SAML options (`saml2-pure`, `saml2-bearer`):**
|
|
634
|
-
`mcp-auth` hands these subcommands to `mcp-sso` with every argument unchanged, so the SAML options
|
|
635
|
-
are `mcp-sso`'s (see *CLI: mcp-sso* and *SAML assertion validation* below) and its exit code is
|
|
636
|
-
`mcp-auth`'s. The ones a run needs:
|
|
637
|
-
|
|
638
|
-
| Option | What it is |
|
|
639
|
-
|---|---|
|
|
640
|
-
| `--idp-metadata <url\|path>` | The identity provider's SAML metadata; fills `--idp-cert`, `--idp-entity-id` and `--idp-sso-url`. For SAP Cloud Identity Services: `https://<tenant>.accounts.ondemand.com/saml2/metadata`. |
|
|
641
|
-
| `--idp-cert <path>`, `--idp-entity-id <id>` | The same trust, stated instead of read. |
|
|
642
|
-
| `--idp-initiated` | The identity provider starts the login. `saml2-bearer` against XSUAA needs it. |
|
|
643
|
-
| `--sp-entity-id`, `--acs-url` | The `Audience` and `Recipient`. For `saml2-bearer` with `--service-key`, read from `<uaa.url>/saml/metadata`. |
|
|
644
|
-
| `--assertion <base64>`, `--assertion-flow <flow>` | A `SAMLResponse` obtained elsewhere, or how to obtain one. |
|
|
645
|
-
| `--authn-request-id <id>` | The request an `--assertion` answers, when `mcp-sso` did not send it. |
|
|
646
|
-
|
|
647
|
-
`saml2-bearer` still requires `--dev`: it has not been run against a live XSUAA with a SAML trust.
|
|
648
|
-
|
|
649
|
-
**Examples:**
|
|
650
|
-
```bash
|
|
651
|
-
# Auth code (default via service key)
|
|
652
|
-
mcp-auth auth-code --service-key ./abap.json --output ./abap.env --type abap
|
|
653
|
-
|
|
654
|
-
# OIDC SSO (device flow example)
|
|
655
|
-
mcp-auth oidc --flow device --issuer https://issuer --client-id my-client --output ./sso.env --type xsuaa
|
|
656
|
-
|
|
657
|
-
# SAML2 pure (cookie); the SAML flags are mcp-sso's, see "SAML assertion validation" below
|
|
658
|
-
mcp-auth saml2-pure --idp-sso-url https://idp/sso --sp-entity-id my-sp --idp-cert ./idp-signing.pem --idp-entity-id https://idp.example/metadata --output ./saml.env --type abap
|
|
659
|
-
|
|
660
|
-
# SAML2 bearer (in progress, requires --dev)
|
|
661
|
-
mcp-auth saml2-bearer --dev --service-key ./mcp.json --idp-metadata https://<ias-tenant>.accounts.ondemand.com/saml2/metadata --idp-initiated --output ./sso.env --type xsuaa
|
|
662
|
-
|
|
663
|
-
# ABAP: authorization_code (default, opens browser)
|
|
664
|
-
mcp-auth --service-key ./abap.json --output ./abap.env --type abap
|
|
665
|
-
|
|
666
|
-
# ABAP: authorization_code (show URL in console, no browser)
|
|
667
|
-
mcp-auth --service-key ./abap.json --output ./abap.env --type abap --browser none
|
|
668
|
-
|
|
669
|
-
# XSUAA: authorization_code (default)
|
|
670
|
-
mcp-auth --service-key ./mcp.json --output ./mcp.env --type xsuaa
|
|
671
|
-
|
|
672
|
-
# XSUAA: client_credentials (special cases)
|
|
673
|
-
mcp-auth --service-key ./mcp.json --output ./mcp.env --type xsuaa --credential
|
|
674
|
-
|
|
675
|
-
# Using existing .env for refresh token
|
|
676
|
-
mcp-auth --env ./mcp.env --service-key ./mcp.json --output ./mcp.env --type xsuaa
|
|
677
|
-
```
|
|
678
|
-
|
|
679
|
-
### CLI: mcp-sso
|
|
680
|
-
|
|
681
|
-
Get tokens via SSO providers (OIDC/SAML) and generate `.env`/JSON output:
|
|
682
|
-
|
|
683
|
-
```bash
|
|
684
|
-
mcp-sso <oidc|saml2|bearer> [options]
|
|
685
|
-
mcp-sso --protocol <oidc|saml2> --flow <flow> --output <path> [--type abap|xsuaa] [--format env|json] [--env <path>] [--config <path>]
|
|
686
|
-
```
|
|
687
|
-
|
|
688
|
-
**Supported flows:**
|
|
689
|
-
- OIDC: `browser`, `device`, `password`, `token_exchange`
|
|
690
|
-
- SAML2: `bearer`, `pure`
|
|
691
|
-
|
|
692
|
-
Only the flows that actually open a browser (OIDC `browser`; SAML2 `bearer`/`pure` with the
|
|
693
|
-
default `--assertion-flow browser`) use `--browser` and `--redirect-port` — they are routed
|
|
694
|
-
into `browserCallbackStrategy`/`oidcCallbackStrategy`/`samlCallbackStrategy`. This CLI has no
|
|
695
|
-
default of its own for the callback port: `--redirect-port` overrides it when given; omitted,
|
|
696
|
-
the port comes from `auth-providers` (currently `61001`). A login is given 5 minutes to
|
|
697
|
-
complete. `device`, `password`, and `token_exchange` never open a browser from this process, so
|
|
698
|
-
`--browser`/`--redirect-port` have no effect for them. This applies the same way whether
|
|
699
|
-
`--protocol`/`--flow` come from CLI flags or from `--config` (below) — a field's origin doesn't
|
|
700
|
-
change how it's handled, and CLI flags always take precedence over the same field in a file.
|
|
701
|
-
|
|
702
|
-
**Examples:**
|
|
703
|
-
```bash
|
|
704
|
-
# OIDC browser flow
|
|
705
|
-
mcp-sso oidc --flow browser --issuer https://issuer --client-id my-client --output ./sso.env --type xsuaa
|
|
706
|
-
|
|
707
|
-
# OIDC browser flow (manual code / OOB — no callback server is opened for this combination)
|
|
708
|
-
mcp-sso oidc --flow browser --token-endpoint https://issuer/token --client-id my-client --code <auth_code> --redirect-uri urn:ietf:wg:oauth:2.0:oob --output ./sso.env --type xsuaa
|
|
709
|
-
|
|
710
|
-
# OIDC device flow
|
|
711
|
-
mcp-sso oidc --flow device --issuer https://issuer --client-id my-client --output ./sso.env --type xsuaa
|
|
712
|
-
|
|
713
|
-
# OIDC password flow
|
|
714
|
-
mcp-sso oidc --flow password --token-endpoint https://issuer/oauth/token --client-id my-client --username user --password pass --output ./sso.env --type xsuaa
|
|
715
|
-
|
|
716
|
-
# OIDC token exchange
|
|
717
|
-
mcp-sso oidc --flow token_exchange --issuer https://issuer --client-id my-client --subject-token <token> --output ./sso.env --type xsuaa
|
|
718
|
-
|
|
719
|
-
# SAML bearer flow against XSUAA with a service key: the Audience, Recipient and token alias
|
|
720
|
-
# come from <uaa.url>/saml/metadata, the IdP's trust from its own metadata
|
|
721
|
-
mcp-sso bearer --service-key ./service-key.json --idp-metadata https://<ias-tenant>.accounts.ondemand.com/saml2/metadata --idp-initiated --output ./sso.env --type xsuaa
|
|
722
|
-
|
|
723
|
-
# The same, every value stated (IdP-initiated assertion -> token)
|
|
724
|
-
mcp-sso bearer --idp-sso-url https://idp/sso --sp-entity-id <uaa-entity-id> --acs-url <uaa-bearer-acs> --idp-cert ./idp-signing.pem --idp-entity-id https://idp.example/metadata --idp-initiated --token-endpoint https://uaa.example/oauth/token --assertion <base64> --output ./sso.env --type xsuaa
|
|
725
|
-
|
|
726
|
-
# SAML pure flow (cookie; SP-initiated browser login, the request is sent by mcp-sso)
|
|
727
|
-
mcp-sso saml2 --flow pure --idp-sso-url https://idp/sso --sp-entity-id my-sp --idp-cert ./idp-signing.pem --idp-entity-id https://idp.example/metadata --cookie "SAP_SESSION=..." --output ./sso.env --type abap
|
|
728
|
-
```
|
|
729
|
-
|
|
730
|
-
**SAML assertion validation:**
|
|
731
|
-
Both SAML flows validate every assertion before using it — signature, issuer, audience,
|
|
732
|
-
recipient, time window, request ID and replay (done by `@mcp-abap-adt/auth-providers` 4; see its
|
|
733
|
-
README, *SAML assertion validation*). The provider will not even be constructed without the
|
|
734
|
-
trust it checks against, and `mcp-sso` invents none of it — it is stated, or read from SAML
|
|
735
|
-
metadata:
|
|
736
|
-
|
|
737
|
-
| Option | `--config` field | What it is |
|
|
738
|
-
|---|---|---|
|
|
739
|
-
| `--idp-cert <path>` (repeatable) | `idpCertificates` (string or list, inline PEM or base64 DER) | The identity provider's signing certificate(s). A file may be PEM (one or several certificates) or binary DER. Repeat the flag, or list several, to trust both keys during a rotation. |
|
|
740
|
-
| `--idp-entity-id <id>` | `idpEntityId` | The identity provider's `entityID` — the `Issuer` its assertions carry. |
|
|
741
|
-
| `--idp-metadata <url\|path>` | `idpMetadata` | The identity provider's SAML metadata (for SAP Cloud Identity Services `https://<tenant>.accounts.ondemand.com/saml2/metadata`). Fills the two rows above and `--idp-sso-url` where not given: signing keys and keys without `use`, never encryption keys. An https URL or a file; plain http only for loopback. Federation metadata (an `EntitiesDescriptor` of several entities) works too: entity ID, keys and SSO URL all come from the same identity provider, which `--idp-entity-id` names when there are several — without it such a run stops and lists them. |
|
|
742
|
-
| `--sp-entity-id <id>` | `spEntityId` | Already required; it is now also the `Audience` the assertion must name. For bearer against UAA/XSUAA, the `entityID` in their SAML metadata. |
|
|
743
|
-
| `--acs-url <url>` | `acsUrl` | The `Recipient` the assertion must name. For bearer against UAA/XSUAA, the token endpoint's bearer ACS; the default `http://localhost:<port>/callback` fits only a login delivered to this CLI. |
|
|
744
|
-
| `--idp-initiated` | `idpInitiated` (`true`/`false`) | The identity provider starts the login and no AuthnRequest is sent, so the assertion must carry no `InResponseTo`. |
|
|
745
|
-
| `--authn-request-id <id>` | `authnRequestId` | The AuthnRequest ID an `--assertion` answers, when the request was sent by something other than `mcp-sso`. |
|
|
746
|
-
|
|
747
|
-
A `--idp-cert` on the command line replaces the file's `idpCertificates` rather than adding to
|
|
748
|
-
them, so a certificate retired on the command line is not still trusted from the file.
|
|
749
|
-
|
|
750
|
-
Which request setting a run needs:
|
|
751
|
-
|
|
752
|
-
- **Browser or manual login, SP-initiated** (`--assertion-flow browser`, the default, or
|
|
753
|
-
`manual`): nothing — `mcp-sso` builds the AuthnRequest and knows its ID.
|
|
754
|
-
- **`--assertion <base64>`** from an SP-initiated login sent elsewhere: `--authn-request-id`.
|
|
755
|
-
- **IdP-initiated** — required for `bearer` against UAA or XSUAA, whose saml2-bearer grant refuses
|
|
756
|
-
an assertion carrying `InResponseTo`: `--idp-initiated`, with `--assertion`, or with
|
|
757
|
-
`--assertion-flow manual` (the default under `--idp-initiated`), which asks you to start the
|
|
758
|
-
login at the identity provider and paste the `SAMLResponse` it posts. `--idp-initiated` with
|
|
759
|
-
`--assertion-flow browser` is refused, since there is no request URL to open.
|
|
760
|
-
|
|
761
|
-
`--idp-initiated` together with `--authn-request-id`, a missing certificate or entity ID, or an
|
|
762
|
-
assertion that fails a check is reported by `auth-providers` itself (`ValidationError` or
|
|
763
|
-
`AssertionValidationError`), with the field or the check it refused.
|
|
764
|
-
|
|
765
|
-
**XSUAA's side of a bearer run:**
|
|
766
|
-
None of `--sp-entity-id`, `--acs-url` and the bearer token endpoint is in an XSUAA service key, but
|
|
767
|
-
XSUAA publishes all three in its SAML metadata: its `entityID` is the `Audience`, and its
|
|
768
|
-
`/oauth/token/alias/<alias>` endpoint is both the `Recipient` and where the assertion is exchanged.
|
|
769
|
-
With `--service-key`, `bearer` reads `<uaa.url>/saml/metadata` and fills whichever of them was not
|
|
770
|
-
given. Without network access to it, pass the file (from *Security > Trust Configuration >
|
|
771
|
-
Download SAML Metadata* in the subaccount):
|
|
772
|
-
|
|
773
|
-
```bash
|
|
774
|
-
mcp-sso bearer --saml-metadata ./saml-sp.xml --idp-sso-url https://idp/sso --sp-entity-id <uaa-entity-id> --acs-url <uaa-bearer-acs> --idp-cert ./idp-signing.pem --idp-entity-id https://idp.example/metadata --idp-initiated --assertion <base64> --service-key ./service-key.json --output ./sso.env --type xsuaa
|
|
775
|
-
```
|
|
776
|
-
|
|
777
|
-
### Local Keycloak (OIDC + SAML Tests)
|
|
778
|
-
|
|
779
|
-
For local testing of `mcp-sso`, a ready-to-run Keycloak setup is included
|
|
780
|
-
(OIDC browser/password/device + SAML assertion capture).
|
|
781
|
-
|
|
782
|
-
```bash
|
|
783
|
-
cd tests/keycloak
|
|
784
|
-
docker compose up -d
|
|
785
|
-
```
|
|
786
|
-
|
|
787
|
-
Then use:
|
|
788
|
-
```bash
|
|
789
|
-
node dist/bin/mcp-sso.js \
|
|
790
|
-
oidc \
|
|
791
|
-
--flow browser \
|
|
792
|
-
--issuer http://localhost:8080/realms/mcp-sso \
|
|
793
|
-
--client-id mcp-sso-cli \
|
|
794
|
-
--scopes openid,profile,email \
|
|
795
|
-
--output /tmp/keycloak.env \
|
|
796
|
-
--type xsuaa
|
|
797
|
-
```
|
|
798
|
-
|
|
799
|
-
See `tests/keycloak/README.md` for device flow and SAML examples.
|
|
800
|
-
|
|
801
|
-
### XSUAA Demo (CAP)
|
|
802
|
-
|
|
803
|
-
A minimal CAP app for testing XSUAA flows is included at `tests/sso-demo`.
|
|
804
|
-
It enables `authorization_code` and `saml2-bearer` grant types and provides a
|
|
805
|
-
simple `CatalogService`. See `tests/sso-demo/readme.md` for deploy steps.
|
|
806
|
-
|
|
807
|
-
**Config file:**
|
|
808
|
-
You can pass a JSON file with provider config instead of (or alongside) `--protocol`/`--flow`
|
|
809
|
-
and the OIDC/SAML flags — `--config` alone is enough to run a flow, with no other flags required:
|
|
810
|
-
|
|
811
|
-
```json
|
|
812
|
-
{
|
|
813
|
-
"protocol": "oidc",
|
|
814
|
-
"flow": "device",
|
|
815
|
-
"issuerUrl": "https://issuer",
|
|
816
|
-
"clientId": "my-client",
|
|
817
|
-
"scopes": ["openid", "profile"]
|
|
818
|
-
}
|
|
819
|
-
```
|
|
820
|
-
|
|
821
|
-
Any CLI flag given alongside `--config` overrides the same field in the file; a field the file
|
|
822
|
-
sets and no flag overrides is used as-is. A file written for a pre-2.0.0 config still works:
|
|
823
|
-
`browser` and `redirectPort` are routed into the strategy exactly as the equivalent CLI flags
|
|
824
|
-
are, and `authorizationCode`/`assertionFlow` are honored the same way `--code`/`--assertion-flow`
|
|
825
|
-
are. A file that sets `authorizationCodeProvider`, `assertionProvider`, or `manualInput` — all
|
|
826
|
-
functions, which JSON cannot express — is refused with an error naming the CLI flag to use
|
|
827
|
-
instead, rather than having the field silently dropped.
|
|
828
|
-
|
|
829
|
-
A SAML config file carries the trust inline:
|
|
830
|
-
|
|
831
|
-
```json
|
|
832
|
-
{
|
|
833
|
-
"protocol": "saml2",
|
|
834
|
-
"flow": "bearer",
|
|
835
|
-
"idpSsoUrl": "https://idp.example/sso",
|
|
836
|
-
"spEntityId": "https://uaa.example/entity",
|
|
837
|
-
"acsUrl": "https://uaa.example/oauth/token/alias/example",
|
|
838
|
-
"idpEntityId": "https://idp.example/metadata",
|
|
839
|
-
"idpCertificates": ["MIIC...base64 DER from the IdP metadata's <X509Certificate>..."],
|
|
840
|
-
"idpInitiated": true,
|
|
841
|
-
"assertionFlow": "manual"
|
|
842
|
-
}
|
|
843
|
-
```
|
|
844
|
-
|
|
845
|
-
#### Migrating `mcp-sso` SAML runs from 2.2.0
|
|
846
|
-
|
|
847
|
-
2.2.0 used `@mcp-abap-adt/auth-providers` 2.x, which trusted any SAML payload it was handed.
|
|
848
|
-
With 4.x every `mcp-sso` SAML run (`bearer`, `saml2 --flow pure`, and `mcp-auth saml2-pure` /
|
|
849
|
-
`saml2-bearer`, which call it) fails before login until you add:
|
|
850
|
-
|
|
851
|
-
1. `--idp-metadata <url|path>`, or `--idp-cert <path>` and `--idp-entity-id <id>` (or
|
|
852
|
-
`idpCertificates` and `idpEntityId` in `--config`) — without them the provider refuses to
|
|
853
|
-
construct.
|
|
854
|
-
2. The real `--sp-entity-id` (the `Audience`) and, unless the assertion is delivered to this CLI's
|
|
855
|
-
own callback, the `--acs-url` it names as `Recipient`. For `bearer` with `--service-key` both
|
|
856
|
-
are read from XSUAA's metadata.
|
|
857
|
-
3. For `bearer` against UAA or XSUAA: `--idp-initiated`, with `--assertion` or
|
|
858
|
-
`--assertion-flow manual`. For any other `--assertion` from an SP-initiated login:
|
|
859
|
-
`--authn-request-id`.
|
|
860
|
-
|
|
861
|
-
Node.js 22, 24 or 26 is required.
|
|
862
|
-
|
|
863
|
-
### Utility Script
|
|
864
|
-
|
|
865
|
-
Generate `.env` files from service keys:
|
|
866
|
-
|
|
867
|
-
```bash
|
|
868
|
-
npm run generate-env <destination> [service-key-path] [session-path]
|
|
869
|
-
```
|
|
870
|
-
|
|
871
1078
|
## Testing
|
|
872
1079
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
### Running Tests
|
|
1080
|
+
The library's tests are in `packages/auth-broker/src/__tests__/` and use Jest.
|
|
1081
|
+
Run them from the repository root, where the dev dependencies are installed:
|
|
876
1082
|
|
|
877
1083
|
```bash
|
|
878
|
-
#
|
|
1084
|
+
# Every workspace's tests (library and CLI)
|
|
879
1085
|
npm test
|
|
880
1086
|
|
|
881
|
-
#
|
|
882
|
-
npm test
|
|
883
|
-
npm test -- refreshToken.test.ts
|
|
884
|
-
|
|
885
|
-
# Run specific test by name/pattern
|
|
886
|
-
npm test -- getToken.test.ts -t "Test 1"
|
|
887
|
-
npm test -- getToken.test.ts -t "Test 2"
|
|
888
|
-
npm test -- getToken.test.ts -t "Test 3"
|
|
889
|
-
|
|
890
|
-
# Run test group (e.g., all getToken tests)
|
|
891
|
-
npm test -- getToken.test.ts
|
|
1087
|
+
# The library's only
|
|
1088
|
+
npm test -w @mcp-abap-adt/auth-broker
|
|
892
1089
|
|
|
893
|
-
#
|
|
894
|
-
|
|
895
|
-
npm test --
|
|
1090
|
+
# One file, or one case
|
|
1091
|
+
npm test -w @mcp-abap-adt/auth-broker -- AuthBroker.test.ts
|
|
1092
|
+
npm test -w @mcp-abap-adt/auth-broker -- AuthBroker.test.ts -t "name of the case"
|
|
896
1093
|
```
|
|
897
1094
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
- Requires: `NO_EXISTS.json` should NOT exist
|
|
904
|
-
|
|
905
|
-
2. **Test 2**: Tests browser authentication when service key exists but `.env` file doesn't
|
|
906
|
-
- Requires: `TRIAL.json` must exist, `TRIAL.env` should NOT exist
|
|
907
|
-
- Will open browser for OAuth authentication
|
|
908
|
-
|
|
909
|
-
3. **Test 3**: Tests token refresh using existing `.env` file
|
|
910
|
-
- Requires: `TRIAL.json` and `TRIAL.env` must exist
|
|
911
|
-
- Can run independently if `.env` file exists (created manually or by Test 2)
|
|
1095
|
+
Tests run sequentially (`maxWorkers: 1` and `maxConcurrency: 1` in
|
|
1096
|
+
`packages/auth-broker/jest.config.js`). `AuthBroker.test.ts` needs nothing;
|
|
1097
|
+
`AuthBroker.integration.test.ts` reads real service keys and sessions, and
|
|
1098
|
+
every case in it returns early unless the configuration below names a real
|
|
1099
|
+
destination.
|
|
912
1100
|
|
|
913
1101
|
### Test Setup
|
|
914
1102
|
|
|
915
|
-
1. Copy `tests/test-config.yaml.template` to
|
|
1103
|
+
1. Copy `packages/auth-broker/tests/test-config.yaml.template` to
|
|
1104
|
+
`packages/auth-broker/tests/test-config.yaml` (until the workspace layout it
|
|
1105
|
+
lived in `tests/` at the repository root; move an existing copy)
|
|
916
1106
|
2. Fill in configuration values (paths, destinations, MCP URL for XSUAA)
|
|
917
1107
|
3. Place service key files in configured `service_keys_dir`:
|
|
918
1108
|
- `{destination}.json` for ABAP tests (e.g., `trial.json`)
|
|
919
1109
|
- `{btp_destination}.json` for XSUAA tests (e.g., `btp.json`)
|
|
920
1110
|
|
|
921
|
-
|
|
1111
|
+
Without `test-config.yaml` the template is read, its placeholders disable every
|
|
1112
|
+
integration case, and the run reaches no system.
|
|
922
1113
|
|
|
923
1114
|
## Documentation
|
|
924
1115
|
|
|
925
|
-
Complete documentation is available in the [`docs/`](docs/) directory:
|
|
1116
|
+
Complete documentation is available in the repository's [`docs/`](../../docs/) directory:
|
|
926
1117
|
|
|
927
|
-
- **[Architecture](docs/architecture/ARCHITECTURE.md)** - System architecture and design decisions
|
|
928
|
-
- **[
|
|
929
|
-
- **[
|
|
930
|
-
- **[
|
|
931
|
-
- **[Usage](docs/using/USAGE.md)** - API reference and usage examples
|
|
1118
|
+
- **[Architecture](../../docs/architecture/ARCHITECTURE.md)** - System architecture and design decisions
|
|
1119
|
+
- **[Testing](../../docs/development/TESTING.md)** - Where the suites live, what they need, the release checks
|
|
1120
|
+
- **[Installation](../../docs/installing/INSTALLATION.md)** - Installation and setup guide
|
|
1121
|
+
- **[Usage](../../docs/using/USAGE.md)** - API reference and usage examples
|
|
932
1122
|
|
|
933
|
-
See [docs/README.md](docs/README.md) for the complete documentation index.
|
|
1123
|
+
See [docs/README.md](../../docs/README.md) for the complete documentation index.
|
|
934
1124
|
|
|
935
1125
|
## Contributors
|
|
936
1126
|
|
|
937
|
-
Thank you to all contributors! See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the complete list.
|
|
1127
|
+
Thank you to all contributors! See [CONTRIBUTORS.md](../../CONTRIBUTORS.md) for the complete list.
|
|
938
1128
|
|
|
939
1129
|
## License
|
|
940
1130
|
|