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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,141 @@ Thank you to all contributors! See [CONTRIBUTORS.md](../../CONTRIBUTORS.md) for
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [4.1.0] - 2026-10-05
15
+
16
+ **x509 service keys.** A client that authenticates to the authorization
17
+ server with a certificate instead of a secret — an XSUAA service key created
18
+ with `{"credential-type": "x509"}` — when the consumer says so, through a
19
+ strategy. Additive: without the new option every destination is served
20
+ exactly as by 4.0.0, and nothing certificate-related is read. Released with
21
+ `@mcp-abap-adt/auth-broker-cli` 2.1.0.
22
+
23
+ ### Added
24
+
25
+ - **`AuthBrokerConfig.clientAuthentication`** — a `ClientAuthenticationStrategy`
26
+ `(context) => Promise<IClientAuthentication>`: how the client of every grant
27
+ that authenticates one (`authorization_code`, `client_credentials`,
28
+ `passcode`, `oidc_authorization_code`, `device_code`, `password`,
29
+ `token_exchange`, `saml2_bearer` — `ClientAuthenticationGrant`)
30
+ authenticates. Called once per build with a `ClientAuthenticationContext`
31
+ (`destination`, `grant`, the key store's secret `client` — `uaaUrl`,
32
+ `uaaClientId`, `uaaClientSecret` only, never a refresh token — or `null`, and a
33
+ lazy, memoised `readCertificate()`); its answer goes to the provider as
34
+ `clientAuthentication`, with no client secret. No default and no "nothing"
35
+ answer: a stated choice never falls back to the secret.
36
+ - **`fromServiceKeyCertificate()`** — auth-providers' `tlsClientCertificate`
37
+ from the key store's certificate client, against `<certUrl>/oauth/token`
38
+ (a trailing `/` of `certUrl` dropped), the material checked before it
39
+ answers. **`fromServiceKeySecret({ encoding: 'raw' | 'form' })`** —
40
+ `clientSecretBasic` with the secret client; `encoding` required (`raw` for
41
+ XSUAA, measured). Each fails closed; a fallback is the consumer's
42
+ composition, decided by what the key holds (`readCertificate()`), never by
43
+ catching a refusal.
44
+ - **The certificate client** is read through the optional
45
+ `IServiceKeyStore.getClientCertificate` (`@mcp-abap-adt/interfaces-auth-broker`
46
+ 1.2.0) — auth-stores 3.3.0 answers it for an x509 XSUAA key and for an
47
+ `EnvDestinationStore` stating `SAP_UAA_CLIENT_CERT_PATH`,
48
+ `SAP_UAA_CLIENT_KEY_PATH` and `SAP_UAA_CERT_URL` (`XSUAA_UAA_*`) — only
49
+ when the strategy asks, at most once per build. The row's client is then
50
+ its identity: the secret client's `uaaUrl` / `uaaClientId`, else the
51
+ certificate client's.
52
+ - **The token API factory's fourth argument, `TokenProviderClient`** —
53
+ beside a strategy, for a grant that authenticates a client: the strategy's
54
+ `clientAuthentication`, the client identity (`uaaUrl`, `clientId`) and the
55
+ stored `refreshToken` when the session is bound here — taken only from the
56
+ session read whose binding was checked, never from a client's own read.
57
+ Never a certificate, key or secret. Additive: a 4.0.0 factory keeps working.
58
+ - **Fixed-words refusals**: whatever a strategy, a store it reads or the
59
+ certificate check throws — and an answer that is no `IClientAuthentication`
60
+ — is a `DestinationConfigError` naming `clientAuthentication` before any
61
+ provider exists, its words chosen by the error's class (no certificate, no
62
+ secret, incomplete / expired / unusable certificate, no client
63
+ authentication, the certificate could not be read, the strategy failed); a
64
+ factory that throws beside a strategy is one naming `provider`,
65
+ `clientAuthentication`. No `cause`, nothing of the thrown message, no PEM
66
+ anywhere — log, refusal, error, session store.
67
+ - Exports: `fromServiceKeyCertificate`, `fromServiceKeySecret`, and the
68
+ types `ClientAuthenticationStrategy`, `ClientAuthenticationContext`,
69
+ `ClientAuthenticationGrant`, `FromServiceKeySecretOptions`,
70
+ `TokenProviderClient`, `IClientCertificate`, `IClientAuthentication`.
71
+ - `npm run test:live:x509`: the live check against a BTP subaccount (exact
72
+ `cf target` guard, a GUID ledger, teardown also on failure). Not in CI.
73
+
74
+ ### Changed
75
+
76
+ - **A client row with no client and no strategy names the way out.** The
77
+ `DestinationConfigError` of a UAA, OIDC or `saml2_bearer` destination whose
78
+ key store answers no client (or one without a client id) keeps its
79
+ `missingFields`; its message adds `; a certificate client needs a
80
+ clientAuthentication strategy`.
81
+
82
+ ### On the strategy path (only with `clientAuthentication`)
83
+
84
+ - **The binding is the client's identity**: `issuedBy` from the identity's
85
+ `uaaUrl` and `client_id`, so a certificate destination's tokens are reused
86
+ after the broker is recreated and dropped when the issuer or client id
87
+ changes — and a secret key and an x509 key of the same client id share one
88
+ session (switching keeps its token and refresh token).
89
+ - **A resource neither side states matches**: means with no `serviceUrl`, or
90
+ one that does not parse (the CLI's XSUAA placeholder), and a session stored
91
+ without `issuedFor`; a resource stated on one side only never matches.
92
+ Without a strategy an absent resource still matches nothing.
93
+ - **The token API with a factory**: the destination must state `authType`
94
+ (`jwt` / `saml`) and `grantType` (else `DestinationConfigError` naming
95
+ both); `saml2_pure` and `none` get three arguments; every stored secret —
96
+ the refresh token in `authConfig` and the fourth argument, the token,
97
+ cookies and expiry in `connConfig` — reaches the factory only when the
98
+ session is bound here, and a result without a refresh token carries forward
99
+ only a bound one. (Without a strategy, 4.0.0's carry-over stands.) An
100
+ instance `provider` gets no strategy. What the strategy and the factory
101
+ are handed is built from allowlists, never a store's answer passed whole:
102
+ the client (`uaaUrl`, `uaaClientId`, `uaaClientSecret`), the certificate
103
+ client (its five declared fields), and the seed (`serviceUrl`, `sapClient`,
104
+ `language`, `authType`, `grantType`, and the session secret's fields only
105
+ from a bound read).
106
+
107
+ ### Dependencies
108
+
109
+ `@mcp-abap-adt/interfaces-auth-broker` `^1.2.0` (was `^1.1.0`),
110
+ `@mcp-abap-adt/interfaces-auth` `^3.2.0` (was `^3.0.0`),
111
+ `@mcp-abap-adt/auth-providers` `^5.3.0` (was `^5.2.1`); dev:
112
+ `@mcp-abap-adt/auth-stores` `^3.3.0`.
113
+
114
+ ### Migrating from 4.0.0
115
+
116
+ Nothing is required. Code that matched a `DestinationConfigError`'s message
117
+ for a destination without a client should match `missingFields` or the class
118
+ instead. To use an x509 key: auth-stores `^3.3.0` and
119
+ `clientAuthentication: fromServiceKeyCertificate()` — then read the strategy
120
+ path's rules above (identity binding, bound secrets only, the token API's
121
+ grant refusal, the factory's fourth argument). See *Migrating from 4.0.0* in
122
+ the README.
123
+
124
+ ### Measured, and not
125
+
126
+ Measured: `client_credentials` with an x509 XSUAA key on a BTP trial,
127
+ 2026-10-05 (`credential-types: ["binding-secret", "x509"]`) — the broker
128
+ (`getProvider` and the token API), `mcp-auth --client-auth certificate` and
129
+ `generate-env-from-service-key --client-auth certificate`, each followed by a
130
+ fresh broker over the written destination; all five live cases passed. Not
131
+ measured: `authorization_code` and `passcode` over x509, the OIDC grants and
132
+ `saml2_bearer` with a strategy, ABAP environment service keys with x509.
133
+
134
+ ### Known limitations
135
+
136
+ - **The certificate is pinned for the broker's lifetime.** It is read when a
137
+ destination's provider is built, and the provider is cached per
138
+ destination for as long as the `AuthBroker` lives. An XSUAA x509
139
+ certificate lives about seven days: a long-running process then gets "the
140
+ client certificate has expired" until it builds a new `AuthBroker`, and
141
+ replacing the key or the PEM files changes nothing before that. Rotation is
142
+ out of scope.
143
+ - The certificate refusals use the broker's own three phrases (incomplete /
144
+ expired / could not be used), classified by auth-providers'
145
+ `CertificateMaterialError` flags; the provider's own hint for each is not
146
+ relayed. auth-providers' exported refusal words (a later minor) would
147
+ replace them.
148
+
14
149
  ## [4.0.0] - 2026-10-02
15
150
 
16
151
  **What 4.0.0 is.** The broker hands a process **the credential for a
package/README.md CHANGED
@@ -13,10 +13,13 @@ where means and secrets live is the stores' (`@mcp-abap-adt/auth-stores`, or you
13
13
 
14
14
  **Upgrading from 3.x?** See [*Migrating from 3.x*](#migrating-from-3x) — 4.0.0 needs a session
15
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.
16
+ the contracts of `interfaces-auth` 3. **On 4.0.0?** 4.1.0 is additive — a client that
17
+ authenticates with an x509 certificate, through the `clientAuthentication` strategy (see
18
+ [*How the Client Authenticates*](#how-the-client-authenticates-clientauthentication)); what a
19
+ 4.0.0 consumer may notice is in [*Migrating from 4.0.0*](#migrating-from-400).
17
20
 
18
21
  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
22
+ [`@mcp-abap-adt/auth-broker-cli`](../auth-broker-cli/README.md) (2.1.0, on this
20
23
  version), in the same repository. Up to 3.0.4 they shipped in this package; from 3.1.0 this package
21
24
  has no `bin`. Install the commands with
22
25
  `npm i -g @mcp-abap-adt/auth-broker-cli` — after
@@ -34,6 +37,7 @@ globally for them.
34
37
  - ⚡ **Forced refresh**: `refreshToken()` obtains a new token even when the cached one looks valid — for a caller holding a 401
35
38
  - 🧾 **JWT or SAML cookies**: what the provider returns is saved as a token or as session cookies
36
39
  - 🔑 **No secrets copied**: The client secret stays in the service key; the session store gets tokens only
40
+ - 📜 **x509 service keys**: a client that authenticates with a certificate instead of a secret (`tls_client_auth`), when you say so — `clientAuthentication: fromServiceKeyCertificate()`; the certificate and key are read from the key store, and never reach the session store, a log line or an error
37
41
 
38
42
  ## Installation
39
43
 
@@ -153,7 +157,7 @@ const provider = await broker.getProvider('DEV'); // an IAuthProvider
153
157
  // const connection = createAbapConnection({ url, provider }, logger) …
154
158
  ```
155
159
 
156
- | `authType` / `grantType` | Provider (auth-providers 5.2) | Read from the key store | Read from the session store |
160
+ | `authType` / `grantType` | Provider (auth-providers 5.3) | Read from the key store | Read from the session store |
157
161
  |---|---|---|---|
158
162
  | `basic` (no grant read) | `new BasicAuthProvider(username, password)` | `username`, `password` | nothing |
159
163
  | `snc` (no grant read) | `SncLogonProvider.forSecureLoginClient({ partnerName, qop, sncLib, myName, logger })` | `sncPartnerName` (required); `sncQop`, `sncLib`, `sncMyName` when set | nothing |
@@ -199,6 +203,9 @@ stored refresh token renews it. Without a session the provider is built
199
203
  unseeded and obtains its first token at `prepare()`. `uaaClientSecret: ''` is
200
204
  a public client: `passcode` takes it as no secret; `AuthorizationCodeProvider`
201
205
  and `ClientCredentialsProvider` require a secret, so for them `''` is missing.
206
+ With a `clientAuthentication` strategy no secret is read or required: the
207
+ client authenticates with the strategy's answer — a certificate, say (see *How
208
+ the Client Authenticates*).
202
209
 
203
210
  **The `authorization` option** is the interactive half of
204
211
  `authorization_code` and `passcode`: a function of the destination and the
@@ -392,6 +399,284 @@ concurrent first calls share one build, and a build that threw is tried again
392
399
  on the next call. A destination rewritten from outside is picked up by a new
393
400
  broker.
394
401
 
402
+ ### How the Client Authenticates: `clientAuthentication`
403
+
404
+ Every grant whose client authenticates to the authorization server — the UAA
405
+ grants (`authorization_code`, `client_credentials`, `passcode`), the OIDC
406
+ grants (`oidc_authorization_code`, `device_code`, `password`,
407
+ `token_exchange`) and `saml2_bearer` — sends the client's secret, as in 4.0.0,
408
+ unless you tell the broker otherwise. An XSUAA service key created with
409
+ `{"credential-type": "x509"}` holds no secret: it holds a client certificate,
410
+ its private key and the mTLS host the certificate is presented to (`certurl`).
411
+ How the client authenticates is your choice, stated as a strategy — the broker
412
+ never infers it from a key's shape and has no default:
413
+
414
+ ```typescript
415
+ import {
416
+ AuthBroker,
417
+ fromServiceKeyCertificate,
418
+ } from '@mcp-abap-adt/auth-broker';
419
+ import {
420
+ XsuaaServiceKeyStore,
421
+ XsuaaSessionStore,
422
+ } from '@mcp-abap-adt/auth-stores';
423
+
424
+ const broker = new AuthBroker({
425
+ serviceKeyStore: new XsuaaServiceKeyStore('/path/to/keys', {
426
+ grantType: 'client_credentials',
427
+ }),
428
+ sessionStore: new XsuaaSessionStore('/path/to/sessions'),
429
+ // The key's certificate, presented at <certurl>/oauth/token.
430
+ clientAuthentication: fromServiceKeyCertificate(),
431
+ });
432
+
433
+ const provider = await broker.getProvider('mcp'); // its client: the certificate
434
+ ```
435
+
436
+ **The strategy** is `(context) => Promise<IClientAuthentication>`
437
+ (`ClientAuthenticationStrategy`). The broker calls it once when it builds a
438
+ destination's provider, with a `ClientAuthenticationContext`:
439
+
440
+ - `destination` and `grant` (a `ClientAuthenticationGrant`, one of the eight
441
+ above);
442
+ - `client` — the secret client the key store's `getAuthorizationConfig`
443
+ answered, as `uaaUrl`, `uaaClientId` and `uaaClientSecret` only — never a
444
+ refresh token or any other field the store answered with it — or `null`
445
+ (an x509 key answers `null` there, never a client with an empty secret);
446
+ - `readCertificate()` — the key store's certificate client
447
+ (`IClientCertificate`: `uaaUrl`, `clientId`, `certificate`, `key`,
448
+ `certUrl`), read **only when called**, at most once per build; `null` when
449
+ the store holds none or implements no `getClientCertificate`.
450
+
451
+ Its answer goes to the provider as `clientAuthentication`, and **no client
452
+ secret goes with it** (auth-providers 5.3 refuses both). A given strategy
453
+ always answers or throws: there is no "nothing" answer, so an explicit choice
454
+ never falls back to the secret. `saml2_pure`, the `none` rows, `basic` and
455
+ `snc` authenticate no client and never call it.
456
+
457
+ **The two shipped factories** each fail closed:
458
+
459
+ | Factory | Answers | Throws (→ the refusal below) |
460
+ |---|---|---|
461
+ | `fromServiceKeyCertificate()` | auth-providers' `tlsClientCertificate` with the certificate and key `readCertificate()` answers, against `<certUrl>/oauth/token` (a trailing `/` of `certUrl` dropped). The material is checked before the factory answers, so a malformed, incomplete or expired certificate is refused when the provider is built, not at its first token request | the store answers no certificate client: "the destination has no client certificate" |
462
+ | `fromServiceKeySecret({ encoding })` | auth-providers' `clientSecretBasic` with the secret client's `uaaClientSecret`, in an `Authorization: Basic` header. `encoding` is required: `'raw'` for XSUAA (measured — it does not form-decode), `'form'` for UAA and Keycloak (RFC 6749 §2.3.1); anything else is a `TypeError` when the factory is made | no secret client, or an empty secret: "the destination has no client secret" |
463
+
464
+ **Composing them is yours.** A fallback, and its order, is your statement,
465
+ never the broker's. Branch on what the key holds (`readCertificate()` is
466
+ memoised, so the factory reads the same answer); do not `catch` a factory's
467
+ refusal and fall back — that would also swallow an expired, incomplete or
468
+ unreadable certificate and send the secret instead:
469
+
470
+ ```typescript
471
+ import {
472
+ type ClientAuthenticationStrategy,
473
+ fromServiceKeyCertificate,
474
+ fromServiceKeySecret,
475
+ } from '@mcp-abap-adt/auth-broker';
476
+
477
+ // The certificate when the key holds one, else the secret in a Basic header.
478
+ // Decided by what the key holds, never by a failure: an expired, incomplete or
479
+ // unreadable certificate is refused, not replaced by the secret.
480
+ const certificateElseSecret: ClientAuthenticationStrategy = async (context) =>
481
+ (await context.readCertificate())
482
+ ? fromServiceKeyCertificate()(context)
483
+ : fromServiceKeySecret({ encoding: 'raw' })(context);
484
+
485
+ // The certificate for client_credentials only; every other grant its secret.
486
+ const byGrant: ClientAuthenticationStrategy = (context) =>
487
+ context.grant === 'client_credentials'
488
+ ? fromServiceKeyCertificate()(context)
489
+ : fromServiceKeySecret({ encoding: 'raw' })(context);
490
+ ```
491
+
492
+ **Where the certificate comes from** — `IServiceKeyStore.getClientCertificate?`
493
+ (`@mcp-abap-adt/interfaces-auth-broker` 1.2.0, optional), which auth-stores
494
+ 3.3.0 implements:
495
+
496
+ - **`XsuaaServiceKeyStore`** — a key (bare, or wrapped in `credentials`)
497
+ carrying `url`, `clientid`, `certificate`, `key` and `certurl` and no
498
+ `clientsecret` is an x509 key: `getAuthorizationConfig` answers `null`,
499
+ `getClientCertificate` the certificate client. A key carrying both a secret
500
+ and a complete certificate offers both — the secret client as before, the
501
+ certificate client beside it — and your strategy picks; the store does not
502
+ read `credential-type` to choose. A key carrying only part of a
503
+ certificate client is refused by `getClientCertificate` (`incomplete`),
504
+ naming the missing fields, never a value. `AbapServiceKeyStore` holds no
505
+ certificate client.
506
+ - **`EnvDestinationStore`** — three means variables, each a path or a URL,
507
+ never PEM: `SAP_UAA_CLIENT_CERT_PATH`, `SAP_UAA_CLIENT_KEY_PATH`,
508
+ `SAP_UAA_CERT_URL` (`XSUAA_UAA_CLIENT_CERT_PATH`, … with
509
+ `XSUAA_DESTINATION_VARS`), beside `SAP_UAA_URL` / `SAP_UAA_CLIENT_ID` and
510
+ **without** `SAP_UAA_CLIENT_SECRET`. All three and no secret is a
511
+ certificate client (the two files read when `getClientCertificate` is
512
+ called); none of them is 4.0.0's answer; some but not all, or any beside the
513
+ secret, is the store's `ClientCertificateError` from both methods, naming
514
+ the variables. A file that states neither a client id, a secret nor any of
515
+ the three asks its `fallback` — so an `EnvDestinationStore` over an `XsuaaServiceKeyStore`
516
+ answers the x509 key's certificate client. See auth-stores' README for the
517
+ details.
518
+
519
+ ```env
520
+ # A certificate destination — EnvDestinationStore (ABAP_DESTINATION_VARS)
521
+ SAP_AUTH_TYPE=jwt
522
+ SAP_GRANT_TYPE=client_credentials
523
+ SAP_URL=https://your-system.abap.us10.hana.ondemand.com
524
+ SAP_UAA_URL=https://your-account.authentication.us10.hana.ondemand.com
525
+ SAP_UAA_CLIENT_ID=sb-your-app!t12345
526
+ SAP_UAA_CLIENT_CERT_PATH=/home/you/keys/client.crt
527
+ SAP_UAA_CLIENT_KEY_PATH=/home/you/keys/client.key
528
+ SAP_UAA_CERT_URL=https://your-account.authentication.cert.us10.hana.ondemand.com
529
+ ```
530
+
531
+ **Without a strategy nothing changes:** the client secret, exactly as 4.0.0,
532
+ and nothing certificate-related is read — no `getClientCertificate`, no file.
533
+ A client row whose key store answers no client, or one without a client id,
534
+ now says, after 4.0.0's words, `a certificate client needs a clientAuthentication strategy` (see
535
+ *Migrating from 4.0.0*).
536
+
537
+ **With a strategy, what the row requires** is its client's identity —
538
+ `uaaUrl` and `uaaClientId` (the OIDC rows: `uaaClientId`) — taken from the
539
+ secret client when the key store has one, else from the certificate client
540
+ (`uaaUrl`, `clientId`), read through the same memoised `readCertificate`; the
541
+ secret is no longer required.
542
+
543
+ **The binding on the strategy path.** A secret is bound to the client's
544
+ identity (`issuedBy` = `uaaUrl` + `client_id`), never to how the client
545
+ authenticates and never to PEM:
546
+
547
+ - a certificate destination's tokens are stored with `issuedBy` from the
548
+ certificate client's `uaaUrl` and `clientId`, reused after the broker is
549
+ recreated, and dropped after the issuer or the client id changes;
550
+ - a secret key and an x509 key of the **same client id** at the same
551
+ `uaaUrl` (as an XSUAA instance's keys are) share one session: switching the
552
+ destination from one to the other keeps its token and refresh token;
553
+ - the resource (`issuedFor`) matches when both sides state it and it is
554
+ equal — **or when neither does**: means with no `serviceUrl`, or one that
555
+ does not parse (the CLI's XSUAA placeholder is such a URL), and a session
556
+ stored without `issuedFor`. A resource stated on one side only never
557
+ matches. So an XSUAA destination without a service URL reuses its own
558
+ session instead of logging in on every run; the client identity still
559
+ decides. (Without a strategy, 4.0.0's rule stands: an absent resource
560
+ matches nothing.)
561
+
562
+ **The token API with a factory of yours** gets the same strategy. For a
563
+ destination whose grant authenticates a client, the broker resolves the
564
+ strategy first and calls your factory with a **fourth argument**,
565
+ `TokenProviderClient` — never a certificate, a key or a secret:
566
+
567
+ | Field | What it is |
568
+ |---|---|
569
+ | `clientAuthentication` | the strategy's answer, to hand to your provider |
570
+ | `uaaUrl`, `clientId` | the client's identity: the secret client's when the stores hold one, else the certificate client's; absent when there is neither |
571
+ | `refreshToken` | the refresh token the session stored — only when the session is bound to this resource and this client identity, and only from that session read (a refresh token a store's `getAuthorizationConfig` carries is dropped on this path; `authConfig` carries the same bound one) |
572
+
573
+ ```typescript
574
+ import {
575
+ AuthBroker,
576
+ fromServiceKeyCertificate,
577
+ type TokenProviderFactory,
578
+ } from '@mcp-abap-adt/auth-broker';
579
+ import { ClientCredentialsProvider } from '@mcp-abap-adt/auth-providers';
580
+ import {
581
+ XsuaaServiceKeyStore,
582
+ XsuaaSessionStore,
583
+ } from '@mcp-abap-adt/auth-stores';
584
+
585
+ const certificateClient: TokenProviderFactory = (
586
+ destination,
587
+ _authConfig, // null for an x509 key
588
+ _connConfig,
589
+ client,
590
+ ) => {
591
+ if (!client?.clientAuthentication || !client.uaaUrl || !client.clientId) {
592
+ throw new Error(`No client for ${destination}`);
593
+ }
594
+ return new ClientCredentialsProvider({
595
+ uaaUrl: client.uaaUrl,
596
+ clientId: client.clientId,
597
+ clientAuthentication: client.clientAuthentication,
598
+ });
599
+ };
600
+
601
+ const tokenBroker = new AuthBroker({
602
+ serviceKeyStore: new XsuaaServiceKeyStore('/path/to/keys', {
603
+ grantType: 'client_credentials',
604
+ }),
605
+ sessionStore: new XsuaaSessionStore('/path/to/sessions'),
606
+ clientAuthentication: fromServiceKeyCertificate(),
607
+ provider: certificateClient,
608
+ });
609
+
610
+ const token = await tokenBroker.getToken('mcp');
611
+ ```
612
+
613
+ On this path:
614
+
615
+ - the destination must state its `authType` (`jwt` or `saml`) and
616
+ `grantType` — the strategy is told the grant — else a
617
+ `DestinationConfigError` naming `authType`, `grantType`; a `saml2_pure` or
618
+ `none` destination authenticates no client, and its factory gets three
619
+ arguments, as in 4.0.0;
620
+ - every stored secret your factory is seeded with passes only when the
621
+ session is bound to this resource and this client identity: the refresh
622
+ token (in the fourth argument, and in `authConfig` for a secret client), and
623
+ the stored token, cookies and expiry in `connConfig` — so a refresh token
624
+ can never reach another authorization server. A result without a refresh
625
+ token carries forward only a bound one. (Without a strategy, 4.0.0's
626
+ carry-over stands: the stored refresh token whatever its binding.)
627
+ - what the factory is handed is built from allowlists, never a store's
628
+ answer passed whole: `authConfig` is `uaaUrl`, `uaaClientId`,
629
+ `uaaClientSecret` and the bound refresh token; `connConfig` is
630
+ `serviceUrl`, `sapClient`, `language`, `authType`, `grantType`, plus — when
631
+ that same connection read is bound — `authorizationToken`,
632
+ `sessionCookies`, `expiresAt`, `issuedFor`, `issuedBy`. Anything else a
633
+ store answers (a password, grant data, a field outside the type) stays
634
+ behind; without a strategy the reads go through as in 4.0.0;
635
+ - a factory that throws is a `DestinationConfigError` naming `provider`,
636
+ `clientAuthentication`, in fixed words — what it threw is not kept;
637
+ - a provider **instance** given as `provider` is your own composition: the
638
+ token API uses it as given and calls no strategy for it.
639
+
640
+ **A strategy that fails carries nothing out.** Whatever the strategy, a store
641
+ it reads or the certificate check throws — and an answer that is no
642
+ `IClientAuthentication` — becomes a `DestinationConfigError` with
643
+ `missingFields: ['clientAuthentication']`, before any provider exists, in
644
+ fixed words chosen by the error's class only — no `cause`, nothing of the
645
+ thrown message:
646
+
647
+ | Words (after `Destination "<name>": `) | When |
648
+ |---|---|
649
+ | `the clientAuthentication strategy refused: the destination has no client certificate` | `fromServiceKeyCertificate()`, no certificate client |
650
+ | `the clientAuthentication strategy refused: the destination has no client secret` | `fromServiceKeySecret()`, no secret client or an empty secret |
651
+ | `the clientAuthentication strategy refused: the client certificate is incomplete` / `has expired` / `could not be used` | the certificate check refused the material |
652
+ | `the clientAuthentication strategy answered no client authentication` | the answer has no `authenticate` function |
653
+ | `the client certificate could not be read` | the store failed reading the certificate client for the row's identity |
654
+ | `the clientAuthentication strategy failed` | anything else — your strategy's own error, a store's error (an incomplete `.env`), an unreadable file |
655
+
656
+ A certificate, a key or a file's content never reaches a log line, a refusal,
657
+ a `DestinationConfigError`, a thrown message or the session store.
658
+
659
+ **The certificate is pinned for the broker's lifetime.** It is read when the
660
+ destination's provider is built, and that provider is cached per destination
661
+ for as long as the `AuthBroker` lives. An XSUAA x509 key's certificate lives
662
+ about seven days: a long-running process then gets `the client certificate
663
+ has expired` from its provider until it builds a new `AuthBroker`, and
664
+ replacing the key or the PEM files changes nothing before that. Rotating
665
+ certificates is out of scope: create a new key, then a new broker (restart).
666
+
667
+ **What is measured.** `client_credentials` with an x509 XSUAA service key,
668
+ against XSUAA on a BTP trial (2026-10-05; the instance's `credential-types`
669
+ `["binding-secret", "x509"]`, its key created with
670
+ `{"credential-type": "x509"}`): through the broker
671
+ (`fromServiceKeyCertificate()` + `XsuaaServiceKeyStore`, `getProvider` and the
672
+ token API), through `mcp-auth --client-auth certificate` and through
673
+ `generate-env-from-service-key --client-auth certificate`, each followed by a
674
+ fresh broker over the written destination — see *Testing*. **Not measured:**
675
+ `authorization_code` and `passcode` over x509 (the broker builds them, nothing
676
+ has run them against XSUAA), the OIDC grants and `saml2_bearer` with a
677
+ strategy, and ABAP environment service keys with x509 (`AbapServiceKeyStore`
678
+ holds no certificate client).
679
+
395
680
  ### Persistence and `flush()`
396
681
 
397
682
  Every token provider `getProvider` builds writes what it obtains back to the
@@ -614,6 +899,44 @@ const newToken = await tokenRefresher.refreshToken();
614
899
  A `@mcp-abap-adt/connection` 10 connector takes an `IAuthProvider` instead:
615
900
  give it `await broker.getProvider('TRIAL')`.
616
901
 
902
+ ## Migrating from 4.0.0
903
+
904
+ 4.1.0 is a minor: nothing to change to keep 4.0.0's behaviour. Without a
905
+ `clientAuthentication` strategy every destination is served as before and
906
+ nothing certificate-related is read. What a 4.0.0 consumer may still notice:
907
+
908
+ 1. **A refusal's message gains a hint.** A UAA, OIDC or `saml2_bearer`
909
+ destination whose key store answers no client (or one without a client
910
+ id) is still a `DestinationConfigError` with the same `missingFields`; its
911
+ message now ends with `; a certificate client needs a clientAuthentication
912
+ strategy`. Match on `missingFields` or the class, never the message.
913
+ 2. **Dependencies.** `@mcp-abap-adt/interfaces-auth-broker` `^1.2.0` (the
914
+ optional `IServiceKeyStore.getClientCertificate` and `IClientCertificate`),
915
+ `@mcp-abap-adt/interfaces-auth` `^3.2.0` and `@mcp-abap-adt/auth-providers`
916
+ `^5.3.0` (`IClientAuthentication`). A key store that reads x509 keys is
917
+ auth-stores `^3.3.0`; an older store simply has no certificate client.
918
+ 3. **New exports:** `fromServiceKeyCertificate`, `fromServiceKeySecret`, and
919
+ the types `ClientAuthenticationStrategy`, `ClientAuthenticationContext`,
920
+ `ClientAuthenticationGrant`, `FromServiceKeySecretOptions`,
921
+ `TokenProviderClient`, `IClientCertificate`, `IClientAuthentication`.
922
+
923
+ **Adopting a strategy** (`clientAuthentication`, see *How the Client
924
+ Authenticates*) changes, for the destinations it applies to:
925
+
926
+ - **No secret goes to the provider** — the strategy's answer does; a
927
+ provider's constructor refuses both.
928
+ - **A secret is bound to the client's identity**: a secret key and an x509
929
+ key of the same client id at the same `uaaUrl` share one session, so
930
+ switching between them keeps the token and the refresh token; a destination
931
+ stating no parseable `serviceUrl` reuses a session stored without
932
+ `issuedFor`, which 4.0.0 never did.
933
+ - **The token API with a factory of yours** passes the fourth argument; seeds
934
+ your factory with a stored refresh token, token or cookies only when the
935
+ session is bound here (4.0.0 carried the stored refresh token over whatever
936
+ its binding); refuses a destination that states no `authType` / `grantType`;
937
+ and turns a throwing factory into a `DestinationConfigError` naming
938
+ `provider`, `clientAuthentication`.
939
+
617
940
  ## Migrating from 3.x
618
941
 
619
942
  4.0.0 is a major: what a 3.x consumer must do, in short (the full list is the
@@ -731,6 +1054,9 @@ SAP_ISSUED_FOR=https://your-system.abap.us10.hana.ondemand.com:443?sap-client=10
731
1054
  SAP_ISSUED_BY=https://your-account.authentication.us10.hana.ondemand.com:443?client_id=client_id
732
1055
  ```
733
1056
 
1057
+ A client that authenticates with a certificate states `SAP_UAA_CLIENT_CERT_PATH`,
1058
+ `SAP_UAA_CLIENT_KEY_PATH` and `SAP_UAA_CERT_URL` — paths and a URL, never PEM —
1059
+ in place of `SAP_UAA_CLIENT_SECRET` (see *How the Client Authenticates*).
734
1060
  SAML cookies are `SAP_SESSION_COOKIES_B64` in place of `SAP_JWT_TOKEN`. The
735
1061
  XSUAA stores use the same names with `XSUAA_` (`XSUAA_DESTINATION_VARS`,
736
1062
  `XSUAA_SESSION_VARS`; the URL is `XSUAA_MCP_URL`). Every means key
@@ -759,6 +1085,21 @@ An XSUAA key carries no URL of the resource it authorizes for: state it as
759
1085
  means in an `EnvDestinationStore` (`XSUAA_MCP_URL`, with
760
1086
  `XSUAA_DESTINATION_VARS`) whose `fallback` is the `XsuaaServiceKeyStore`.
761
1087
 
1088
+ An x509 XSUAA key (created with `{"credential-type": "x509"}`) holds a
1089
+ certificate client instead of a secret — the broker uses it only beside a
1090
+ `clientAuthentication` strategy (see *How the Client Authenticates*):
1091
+
1092
+ ```json
1093
+ {
1094
+ "url": "https://your-account.authentication.us10.hana.ondemand.com",
1095
+ "clientid": "sb-your-app!t12345",
1096
+ "certificate": "-----BEGIN CERTIFICATE-----\n...",
1097
+ "key": "-----BEGIN RSA PRIVATE KEY-----\n...",
1098
+ "certurl": "https://your-account.authentication.cert.us10.hana.ondemand.com",
1099
+ "credential-type": "x509"
1100
+ }
1101
+ ```
1102
+
762
1103
  ## Responsibilities and Design Principles
763
1104
 
764
1105
  ### Core Development Principle
@@ -857,6 +1198,7 @@ new AuthBroker(
857
1198
  destination: string,
858
1199
  authConfig: IAuthorizationConfig | null,
859
1200
  connConfig: IConnectionConfig,
1201
+ client?: TokenProviderClient, // only beside clientAuthentication (4.1.0)
860
1202
  ) => IRefreshableTokenProvider);
861
1203
  // Collaborators, each a function of the destination, called once per
862
1204
  // build, never disposed by the broker:
@@ -865,6 +1207,8 @@ new AuthBroker(
865
1207
  deviceCodePresenter?: (destination: string) => IDeviceCodePresenter; // device_code
866
1208
  samlCookies?: (destination: string) => (samlResponse: string) => Promise<string>; // saml2_pure
867
1209
  assertionReplayStore?: (destination: string) => IAssertionReplayStore; // saml2_pure, saml2_bearer
1210
+ // How a client authenticates (4.1.0): every grant that authenticates one
1211
+ clientAuthentication?: ClientAuthenticationStrategy;
868
1212
  },
869
1213
  logger?: ILogger,
870
1214
  )
@@ -873,12 +1217,13 @@ new AuthBroker(
873
1217
  **Parameters:**
874
1218
  - `config.sessionStore` - **Required** - The session secret: the token or cookies, `expiresAt`, the refresh token, and what it is bound to (`issuedFor`, `issuedBy`). For the token API with a `provider`, its `serviceUrl`, or the service key's, is required (3.x's reads).
875
1219
  - `config.serviceKeyStore` - The means: `authType`, `grantType`, the client, basic's user and password, the SNC, OIDC and SAML fields, `serviceUrl`. **Required by `getProvider`**, which has no other source of means.
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.
1220
+ - `config.provider` - The token API's source (`getToken`, `refreshToken`, `createTokenRefresher`): a provider instance, used for every destination, or a factory (`TokenProviderFactory`), called once per destination and seeded with what the stores hold (see *Basic Usage*); beside a `clientAuthentication` strategy, with a fourth argument (`TokenProviderClient`, see *How the Client Authenticates*). Not used by `getProvider`. Without it, the token API asks the provider `getProvider` builds for the destination (see *Getting Tokens*); with neither it nor a `serviceKeyStore`, the token API throws `DestinationConfigError` naming both.
877
1221
  - `config.authorization` - The interactive strategy of `jwt` / `authorization_code`, `jwt` / `passcode`, `saml` / `saml2_pure` and `saml` / `saml2_bearer`, as a function of the destination and the grant (see *A Provider for a Connector*).
878
1222
  - `config.oidcAuthorization` - The interactive strategy of `jwt` / `oidc_authorization_code`.
879
1223
  - `config.deviceCodePresenter` - Where `jwt` / `device_code` shows the user the verification URL and code.
880
1224
  - `config.samlCookies` - `saml2_pure`: turns the validated SAMLResponse into the system's session cookies.
881
1225
  - `config.assertionReplayStore` - The replay store the SAML validators record each assertion in.
1226
+ - `config.clientAuthentication` - How the client of every grant that authenticates one (the UAA and OIDC grants, `saml2_bearer`) authenticates to the authorization server: a `ClientAuthenticationStrategy` — `fromServiceKeyCertificate()`, `fromServiceKeySecret({ encoding })`, or your own composition. Called once per build; absent, the client secret as in 4.0.0 and nothing certificate-related is read (see *How the Client Authenticates*).
882
1227
  - Each collaborator option is required only by the rows that use it (missing → `DestinationConfigError` naming it); the broker supplies no default, calls it once per build, and disposes nothing it returns.
883
1228
  - `logger` - Optional logger. If not provided, nothing is logged.
884
1229
 
@@ -926,7 +1271,8 @@ The two stores composed, each for its role:
926
1271
  `null` when neither store holds anything.
927
1272
  - `getAuthorizationConfig` — the service key store's client (`uaaUrl`,
928
1273
  `uaaClientId`, `uaaClientSecret`) with the session's refresh token; `null`
929
- when the key store has no client.
1274
+ when the key store has no client — an x509 key's included: it never answers
1275
+ a certificate or a key.
930
1276
 
931
1277
  Means a session store answers (a client, a URL, an `authType`) are not read,
932
1278
  nor a secret a key store answers. Up to 3.1.0 both answered the session's
@@ -974,6 +1320,10 @@ configuration whole, and the key's only when the session had none.
974
1320
  | `device_code` without the `deviceCodePresenter` option | `deviceCodePresenter` |
975
1321
  | a SAML grant without its trust (`''` and an empty certificate list count as missing) | `samlIdpSsoUrl`, `samlSpEntityId`, `samlIdpEntityId`, `samlIdpCertificates` — each that is missing |
976
1322
  | `saml2_bearer` without its client | `uaaUrl`, `uaaClientId` — each that is missing |
1323
+ | a client row (UAA, OIDC, `saml2_bearer`) with no client id and no `clientAuthentication` strategy | as above; the message adds `a certificate client needs a clientAuthentication strategy` |
1324
+ | the `clientAuthentication` strategy threw, refused, or answered no client authentication; the certificate client could not be read | `clientAuthentication` (fixed words, see *How the Client Authenticates*) |
1325
+ | the token API with a `clientAuthentication` strategy and a factory, on a destination that states no `jwt` / `saml` type | `authType`, `grantType` |
1326
+ | the token API's factory threw beside a `clientAuthentication` strategy | `provider`, `clientAuthentication` |
977
1327
  | a SAML grant without its collaborators | `authorization`, `samlCookies` (`saml2_pure`), `assertionReplayStore` — each that is missing |
978
1328
  | a certificate the validator cannot read | `samlIdpCertificates` |
979
1329
  | a `samlClockSkewMs` that is not a whole, non-negative number | `samlClockSkewMs` |
@@ -1111,6 +1461,30 @@ destination.
1111
1461
  Without `test-config.yaml` the template is read, its placeholders disable every
1112
1462
  integration case, and the run reaches no system.
1113
1463
 
1464
+ ### The x509 Live Check
1465
+
1466
+ `npm run test:live:x509` (from the repository root or this package) runs
1467
+ `client_credentials` with an x509 XSUAA service key against a real BTP
1468
+ subaccount: it builds, creates an `xsuaa` / `application` instance
1469
+ (`credential-types: ["binding-secret", "x509"]`) and a key made with
1470
+ `{"credential-type": "x509"}`, runs `src/__tests__/live/x509.live.test.ts`
1471
+ — the broker with `fromServiceKeyCertificate()`, `mcp-auth --client-auth
1472
+ certificate`, `generate-env-from-service-key --client-auth certificate`, a
1473
+ fresh broker over each written destination, and a failing run that leaves the
1474
+ previous destination untouched and prints no PEM — and removes everything,
1475
+ also when a test fails. Not in `npm test`, not in CI.
1476
+
1477
+ It refuses to run unless `XSUAA_CF_API`, `XSUAA_CF_ORG` and `XSUAA_CF_SPACE`
1478
+ equal exactly what `cf target` shows (`cf login -a <api> --sso -o <org> -s
1479
+ <space>` first). It touches only what it created: each resource is recorded
1480
+ with its GUID in a ledger, `tests/live/x509/.local/owned` (gitignored, with
1481
+ the key and its PEM files, owner-only), bound to that API, org and space, and
1482
+ re-checked before every reuse or delete; a name held by anything else is
1483
+ refused. A failed teardown exits non-zero and keeps `.local/` — run
1484
+ `tests/live/x509/teardown.sh` with the same three variables. `X509_KEEP=1`
1485
+ keeps the environment for another run; `-- -t "(b)"` runs one case. Last run:
1486
+ BTP trial, 2026-10-05, all five cases passed.
1487
+
1114
1488
  ## Documentation
1115
1489
 
1116
1490
  Complete documentation is available in the repository's [`docs/`](../../docs/) directory: