@mcp-abap-adt/auth-broker
v5.0.1
Published
Per-destination credential broker for SAP BTP and ABAP: the IAuthProvider a destination states, built from its key store and session store, with every secret it obtains stored back; and a token API
Downloads
5,988
Maintainers
Readme
@mcp-abap-adt/auth-broker
A per-destination credential broker for SAP BTP and ABAP systems. For a destination — a name,
such as TRIAL — getProvider builds the IAuthProvider the destination states (basic, SNC, a
UAA, OIDC or SAML grant, or a credential handed over) from the means in the service key store
and the secret in the session store, ready for a @mcp-abap-adt/connection 14 connector, and
stores back every token or set of SAML session cookies that provider obtains or renews. The
token API (getToken, refreshToken, createTokenRefresher) serves whoever wants a token and
nothing else. It decides nothing about tokens itself: whether the cached token is still good,
when to refresh and when to log in is the provider's call (@mcp-abap-adt/auth-providers) and
the renewal strategy's you give it; where means and secrets live is the stores'
(@mcp-abap-adt/auth-stores, or your own).
Upgrading from 4.x? 5.0.0 is a major: see Migrating to 5.0.0. In
short — a token destination needs two options the broker has no default for (renewal,
onWriteFailure); every failure is an AuthProviderFailure read through
@mcp-abap-adt/auth-errors; every session written before 5.0.0 reads as unbound once, so each
token destination logs in once after the upgrade.
The mcp-auth command that writes destination files is
@mcp-abap-adt/auth-broker-cli (3.0.0, on this version), in the
same repository. This package has no bin.
Features
- 🔌 A credential for a connector:
getProvider(destination)builds theIAuthProviderthe 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 - 🧭 You compose, the broker does not guess: how a token provider renews (
renewal) and what a session write that did not land means (onWriteFailure) are your statements, with no default - 💾 What a provider obtains is stored: every token or set of SAML session cookies a provider obtains or renews — at
prepare(), on expiry, or after a 401 inrejected()— is written to the session store before the provider answers, one write at a time per destination;flush()tells you whether everything landed - 🔒 A credential stays bound to its identity: a stored secret is reused only by a provider built from exactly the means it was obtained under — the resource, the row (
authType/grantType), the client, every server address and the trust; anything else changed, a new provider is built, and it starts with nothing - 🛑 Every wait can be cancelled by whoever waits:
getProvider, the token API andflush()take asignal; the broker sets no timeout of its own - 🧾 Failures as the provider made them: a provider's
AuthProviderFailurereaches you as the same object; the broker's own refusals carry names, never values - 🪙 A token API for whoever wants a token and nothing else:
getToken,refreshTokenandcreateTokenRefresheron the destination's own provider, or on one you give the broker - 📜 x509 service keys: a client that authenticates with a certificate instead of a secret, when you say so —
clientAuthentication: fromServiceKeyCertificate(); the certificate and key never reach the session store, a log line or an error
Installation
npm install @mcp-abap-adt/auth-broker @mcp-abap-adt/auth-providers @mcp-abap-adt/auth-errors @mcp-abap-adt/auth-stores5.0.0 depends on @mcp-abap-adt/auth-providers ^6.0.0, @mcp-abap-adt/auth-errors
^2.1.1, @mcp-abap-adt/interfaces-auth ^7.5.0, @mcp-abap-adt/interfaces-auth-sap
^3.3.0, @mcp-abap-adt/interfaces-auth-broker ^1.3.0 and
@mcp-abap-adt/interfaces-utils ^1.1.0. Declare auth-providers yourself — the renewal
strategies (refreshThenLogin, refreshOnly) and the interactive strategies come from it —
and auth-errors to read a failure. The stores are yours to choose: @mcp-abap-adt/auth-stores
^4.0.0, or any implementation of the @mcp-abap-adt/interfaces-auth-broker contracts. Keep
one installed copy of each contract package (npm ls @mcp-abap-adt/interfaces-auth,
npm ls @mcp-abap-adt/auth-errors): a failure of another copy is still read correctly, but
loses its diagnostics.
Requires Node.js 22, 24 or 26 (engines: "^22 || ^24 || ^26"): 22 and 24 are the versions
SAP BTP's Cloud Foundry Node.js buildpack offers, and 26 is supported as well.
Usage
A Provider for a Connector: getProvider
import { AuthBroker } from '@mcp-abap-adt/auth-broker';
import {
browserCallbackStrategy,
linuxDefaultBrowser,
refreshThenLogin,
} from '@mcp-abap-adt/auth-providers';
import {
AbapServiceKeyStore,
AbapSessionStore,
EnvDestinationStore,
} from '@mcp-abap-adt/auth-stores';
const broker = new AuthBroker(
{
// The means: <destinations>/<name>.env, else the SAP service key <keys>/<name>.json.
serviceKeyStore: new EnvDestinationStore('/path/to/destinations', {
fallback: new AbapServiceKeyStore('/path/to/keys', {
grantType: 'authorization_code',
}),
}),
// The secret: the token or cookies, its expiry, the refresh token, its binding.
sessionStore: new AbapSessionStore('/path/to/sessions'),
// How a token provider renews: refresh, then log in (4.x's steps). Required.
renewal: () => refreshThenLogin(),
// What a session write that did not land means. Required.
onWriteFailure: 'fail',
// The interactive half of authorization_code: the platform's default browser.
authorization: () => browserCallbackStrategy({ browser: linuxDefaultBrowser() }),
},
logger, // optional ILogger
);
const session = new AbortController(); // the connector's session: abort it when the session ends
const provider = await broker.getProvider('TRIAL', { signal: session.signal });
// new AdtCloudConnector({ url, client, authType: 'jwt' }, provider, transport, logger) …getProvider(destination) returns the IAuthProvider (from @mcp-abap-adt/interfaces-auth
7) that a @mcp-abap-adt/connection 14 connector takes as it is. The broker reads two stores,
each for one role:
- the means —
authType,grantType, basic's user and password, the SNC, OIDC and SAML fields,serviceUrl, the client — from the service key store (getConnectionConfig,getAuthorizationConfig, andgetClientCertificatewhen aclientAuthenticationstrategy asks), and only from there; - the secret — the token or session cookies,
expiresAt, the refresh token, and what it is bound to — from the session store (loadSession), and only from there.
Nothing is inferred: the destination's authType (and grantType, for jwt and saml)
decides the provider, never which other fields are present.
| authType / grantType | Provider (auth-providers 6) | Read from the key store | Read from the session store |
|---|---|---|---|
| basic (no grant read) | new BasicAuthProvider(username, password) | username, password | nothing |
| snc (no grant read) | SncLogonProvider.forSecureLoginClient({ partnerName, qop, sncLib, myName, logger }) | sncPartnerName (required); sncQop, sncLib, sncMyName when set | nothing |
| jwt / authorization_code | AuthorizationCodeProvider | the client: uaaUrl, uaaClientId, uaaClientSecret; serviceUrl, sapClient for the binding | the seed: authorizationToken, refreshToken, expiresAt — see A Credential Stays Bound to Its Identity |
| jwt / client_credentials | ClientCredentialsProvider | the client: uaaUrl, uaaClientId, uaaClientSecret; serviceUrl, sapClient for the binding | nothing (the row takes the client alone) |
| jwt / passcode | UaaPasscodeProvider | the client: uaaUrl, uaaClientId, uaaClientSecret ('' = a public client); serviceUrl, sapClient | the seed, as above |
| jwt / oidc_authorization_code | OidcBrowserProvider (PKCE) | uaaClientId, uaaClientSecret ('' = a public client); oidcIssuerUrl, or oidcAuthorizationEndpoint + oidcTokenEndpoint; oidcScopes; serviceUrl, sapClient | the seed, as above |
| jwt / device_code | OidcDeviceFlowProvider | the client as above; oidcIssuerUrl, or oidcDeviceAuthorizationEndpoint + oidcTokenEndpoint; oidcScopes; serviceUrl, sapClient | the seed, as above |
| jwt / password | OidcPasswordProvider | the client as above; username, password; oidcIssuerUrl, or oidcTokenEndpoint; oidcScopes; serviceUrl, sapClient | the seed, as above |
| jwt / token_exchange | OidcTokenExchangeProvider (RFC 8693) | the client as above; oidcSubjectToken, oidcSubjectTokenType; oidcAudience, oidcActorToken, oidcActorTokenType when set; oidcScopes, joined by one space into its scope; oidcIssuerUrl, or oidcTokenEndpoint; serviceUrl, sapClient | nothing: its subject token is a secret and cannot bind a stored session, so it obtains a fresh token after every restart (no refresh grant: a renewal exchanges again) |
| saml / saml2_pure | Saml2PureProvider | samlIdpSsoUrl, samlSpEntityId, samlIdpEntityId (the expected issuer), samlIdpCertificates; samlAcsUrl, samlRelayState, samlIdpInitiated, samlClockSkewMs when set; serviceUrl, sapClient | the seed: sessionCookies, expiresAt |
| 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 |
| jwt / none | TokenAuthProvider.fixed(authorizationToken) | authType, grantType, serviceUrl (+ sapClient); the client and oidcIssuerUrl when stated | authorizationToken (required), issuedFor and issuedBy (required to match exactly) |
| saml / none | new SamlAuthProvider(sessionCookies) | authType, grantType, serviceUrl (+ sapClient); samlAcsUrl when stated | sessionCookies (required), issuedFor and issuedBy (required to match exactly) |
The allowed pairs are jwt with authorization_code, client_credentials, passcode,
oidc_authorization_code, device_code, password, token_exchange or none, and saml
with saml2_pure, saml2_bearer or none. A pair outside them is a DestinationConfigError
naming grantType.
No provider reads serviceUrl. The URL of the system is where the connector connects, not
authorization data: give the connector its URL from your key store (getConnectionConfig). The
broker reads it, with sapClient, for one thing only: to bind a stored secret to the resource
it was obtained for. A token destination without it still gets its provider, but no stored
secret is reused for it (except on the clientAuthentication strategy path, below).
none is how a handed-over credential is stated: the key store says grantType: 'none', and
the token or cookies live in the session. The SNC row takes the contract's own defaults when a
field is absent — the library is discovered (SNC_LIB_64, SNC_LIB, the Secure Login Client's
install path) when sncLib is, the user's SNC name comes from the credential when sncMyName
is, and qop is the provider's '9' when sncQop is.
The UAA grants. The client comes from the key store's getAuthorizationConfig — never from
the session store. A seed is presented while it is valid (a JWT's own exp decides; the stored
expiresAt serves a token that carries none), and its refresh token renews it, as the renewal
strategy says. Without a seed the provider obtains its first token at prepare().
uaaClientSecret: '' is a public client: passcode takes it as no secret;
AuthorizationCodeProvider and ClientCredentialsProvider require a secret, so for them ''
is missing. With a clientAuthentication strategy no secret is read or required (see How the
Client Authenticates).
The OIDC grants. The client is the key store's getAuthorizationConfig again: uaaClientId,
and uaaClientSecret — '' is a public client, sent with no secret. The endpoints come from
oidcIssuerUrl, which the provider discovers them from, or — without it — from every explicit
endpoint the row reads; without either the error names oidcIssuerUrl and the endpoints
missing. oidcScopes is passed as given (token_exchange takes one scope string: the scopes
joined by a space). The subject and actor tokens of token_exchange and the user and password
of password are means: sent to the token endpoint, never written to the session.
The SAML grants. Each provider validates the assertion before anything uses it, with a
validator the broker composes from the destination's trust and your replay store:
createSignedResponseValidator for saml2_pure (the Response must be signed — the cookies'
system receives it whole) and createSignedAssertionValidator for saml2_bearer (the Assertion
must be signed — the token endpoint receives it alone), from samlIdpCertificates (PEM or
base64 DER; several during a rotation), samlClockSkewMs and assertionReplayStore(destination);
samlIdpEntityId is the issuer every assertion must name. A certificate the validator cannot
read is a DestinationConfigError naming samlIdpCertificates and carrying the validator's
error; a samlClockSkewMs that is not a whole, non-negative number of milliseconds is one naming
samlClockSkewMs. samlIdpInitiated: true declares an IdP-initiated login: no AuthnRequest,
and an assertion carrying no InResponseTo — your strategy then hands over the SAMLResponse
without asking for an authorization URL. Cookies carry no expiry of their own, so saml2_pure
keeps them until the assertion's earliest NotOnOrAfter (less the provider's one-minute
margin); SAML has no refresh token, so its renewal is a new login through your strategy.
saml2_bearer posts the Assertion to samlTokenUrl, else <uaaUrl>/oauth/token, with the
client, and renews by its refresh token.
The collaborator options — each a function of the destination, called once per build of
that destination's provider (again for every new build, see A Credential Stays Bound to Its
Identity), never disposed by the broker, and required only by the rows that use it; a row whose
option is missing is a DestinationConfigError naming it:
| Option | Rows | What it returns |
|---|---|---|
| authorization(destination, grant) | authorization_code, passcode, saml2_pure, saml2_bearer (the grant is passed: a StrategyGrant) | the IAuthorizationStrategy<string> that conducts the login — for passcode, the one that asks for the code (handed <uaaUrl>/passcode as the URL); for the SAML grants, the one that returns the SAMLResponse |
| oidcAuthorization(destination) | oidc_authorization_code | an IAuthorizationStrategy<OidcCallbackResult> (oidcCallbackStrategy, or asOidcResult(…) over a string strategy) |
| deviceCodePresenter(destination) | device_code | an IDeviceCodePresenter that shows the user the verification URL and code (consoleDeviceCodePresenter(logger), or your UI) |
| samlCookies(destination) | saml2_pure | (samlResponse) => Promise<string>: posts the validated SAMLResponse to the system's ACS and returns the session cookies it sets |
| 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 |
A strategy you write yourself must honour AuthorizationRequest.signal (auth-providers 6):
when it aborts, the strategy stops waiting and releases what it holds before it settles.
import { AuthBroker } from '@mcp-abap-adt/auth-broker';
import {
browserCallbackStrategy,
consoleDeviceCodePresenter,
defaultReplayStore,
linuxDefaultBrowser,
manualPasscodeStrategy,
oidcCallbackStrategy,
refreshThenLogin,
samlCallbackStrategy,
} from '@mcp-abap-adt/auth-providers';
const browser = linuxDefaultBrowser(); // macDefaultBrowser(), windowsDefaultBrowser(), …
const ssoBroker = new AuthBroker(
{
serviceKeyStore: myKeyStore,
sessionStore: mySessionStore,
renewal: () => refreshThenLogin(),
onWriteFailure: 'fail',
authorization: (destination, grant) =>
grant === 'passcode'
? manualPasscodeStrategy()
: grant === 'saml2_pure' || grant === 'saml2_bearer'
? samlCallbackStrategy({ browser })
: browserCallbackStrategy({ browser }),
oidcAuthorization: () => oidcCallbackStrategy({ browser }),
deviceCodePresenter: () => consoleDeviceCodePresenter(logger),
samlCookies: (destination) => (samlResponse) =>
postToAcs(destination, samlResponse), // yours: the system's ACS answers Set-Cookie
assertionReplayStore: () => defaultReplayStore,
},
logger,
);A login waits until it ends or a signal aborts it — no strategy of auth-providers 6 has a
timeout. A bound is yours to compose: the strategy's signal option, or the signal you pass the
broker (AbortSignal.timeout(ms)).
How a Token Provider Renews: renewal
import type { AuthBrokerConfig } from '@mcp-abap-adt/auth-broker';
import { refreshOnly, refreshThenLogin } from '@mcp-abap-adt/auth-providers';
// The same for every destination: refresh, then log in — what 4.x did.
const sameForAll: AuthBrokerConfig['renewal'] = () => refreshThenLogin();
// Per destination or grant: a headless destination never logs in.
const perDestination: AuthBrokerConfig['renewal'] = (destination, grant) =>
destination === 'HEADLESS' || grant === 'device_code' ? refreshOnly() : refreshThenLogin();renewal(destination, grant) is called once per build of every token row — the UAA, OIDC and
SAML grants (TokenGrant: 'authorization_code' | 'client_credentials' | 'passcode' | 'oidc_authorization_code' | 'device_code' | 'password' | 'token_exchange' | 'saml2_pure' | 'saml2_bearer') — after every
other check of the row has passed and before the provider's constructor; its answer is the provider's renewal,
unchanged. The broker never wraps, inspects or calls it.
- Required for every token row, no default. A token row built without it is a
DestinationConfigErrorwithmissingFieldsnamingrenewal— together with every other field and option the row lacks, in one error — before any collaborator is called and before anything is cached.basic,sncand thenonerows build without it. - A
renewalthat throws is aDestinationConfigError(['renewal']), "the renewal option failed", carrying what it threw as auth-errors reads it (error); nothing is built or cached. A provider that refuses the strategy it was given (one whosenextis not a function) is aDestinationConfigErrornamingrenewaltoo, "the provider refused the configuration the destination states", carrying the provider's error. - Never called for the token API's consumer
provider: an instance or a factory's result is your composition, and brings its own renewal.
refreshThenLogin() refreshes when there is a refresh token and logs in through the row's
interactive strategy when the refresh is refused or there is none. refreshOnly() never logs
in: a headless process uses it, or an authorization strategy that refuses (see Headless
Processes).
Session Writes: onWriteFailure, the Write Queue and flush()
Every token provider getProvider builds writes what it obtains back to the session store
before it answers, whichever moment triggered the renewal: prepare(), authorize() on expiry,
or rejected() after a 401. A renewal inside a connector is stored before the connector resends.
The broker builds each provider's persistence from auth-providers' own
refreshStatePersistence over one write path of its own; you cannot give a provider the broker
builds a persistence strategy of yours, since the broker writes the binding beside the secret.
onWriteFailure: 'fail' suits a command or a test that must know the secret landed;
onWriteFailure: 'continue' a long-running server that goes on best effort. It is required for every destination that writes a secret — a token row
getProvider builds, and every call of the token API with a provider of yours. Without it the
build (or the call) throws DestinationConfigError naming onWriteFailure; basic, snc and
none destinations do not need it. One option serves both paths.
'fail': the call whose write did not land fails —unknown,persisting-tokens("persisting the tokens failed (unknown error, EACCES)", the code only when it is allowlisted): for a provider the broker built, the provider's awaited report fails itsgetTokens()/refreshTokens()or its moment, and the token API relays that failure; on the consumer path the token API rejects with the same failure. And while the destination's last write is pending, its calls are refused until a write lands:getProvider,getTokenandrefreshTokenask "is the destination's last write pending?" on entry and once more right before they return success; each time the answer is yes they retry that write — it lands, the call goes on; it fails, the call rejects with the same failure. "Pending" is any write not yet landed: failed, queued or in flight. The limit: a provider already handed to a connector answers its moments from its own state; a moment that commits nothing (a valid cached token presented) is not refused because of a pending write. Every moment that renews writes, and awaits its write.'continue': no call fails because of a write. A failed write is logged (warn, the failure'slogFieldsonly), stays pending, and the call goes on.
The write queue. The writes of one destination run one at a time, in the order they were
queued, so an older write never runs after — and never overwrites — a newer one; destinations
never wait on each other. A write of a provider that has since been replaced (see A Credential
Stays Bound to Its Identity) is dropped once the new provider has written. A failed write
stays pending — the latest state its provider reported, which every later write of that
provider carries too — and is retried by the destination's next write or by flush(). There
is no retry timer: nothing is retried on its own. Every wait on the queue races its caller's
signal: an abort releases that caller at once with auth-errors' aborted failure — never with
success — and the write runs on, landing or failing on its own.
The store's contract. saveSession settles: it resolves or rejects. A store whose
saveSession never settles holds its destination's queue; avoiding that is yours (each waiting
caller is still released by its own signal). auth-stores 4's saveSession merges — a field
left out keeps what is stored — so the broker states every field that must not survive:
| What the provider reported | What one saveSession writes |
|---|---|
| a token | authorizationToken, expiresAt, refreshToken (below), issuedFor ('' when the means state no serviceUrl), issuedBy |
| saml2_pure's cookies (tokenType: 'saml') | sessionCookies, expiresAt, refreshToken: '' (SAML has none, and one stored beside earlier cookies or a token is not this credential's), issuedFor, issuedBy |
| a refresh token discarded before any credential is held | only refreshToken: '' — the stored credential keeps its own binding and loses its refresh token |
The refresh token a provider owns. Each provider the broker builds owns a refresh token: the
one it was seeded with from a session bound to it, or none; then each write updates it — a new
one written makes it owned, a discard makes it none. A write that reports no new refresh token
writes the owned one, or '' when it owns none — never one read from the store at write time.
So a refresh token is persisted only by the provider that obtained it or was seeded with it and
has not discarded or replaced it since; a refresh token another provider of the destination
wrote never ends up beside this provider's credential. expiresAt is the provider's report,
absolute. A destination the key store states as basic or snc at write time is not written.
flush({ signal? }) waits for every write queued so far and gives each pending one one more
attempt. It resolves when all landed, and rejects with an AggregateError ("Session writes
still failing for "", …; each stays pending until its destination's next write or
flush()") whose errors are one SessionWriteFailure per destination still failing:
destination, and error — the store's error as auth-errors classifies it (unknown,
persisting-tokens), never its message; its own message is "<destination>": <reason>. What
still fails stays pending. Call it on shutdown — on SIGTERM, before a stdio transport closes —
to know whether every token is stored:
import { SessionWriteFailure } from '@mcp-abap-adt/auth-broker';
process.on('SIGTERM', async () => {
try {
await broker.flush();
} catch (error) {
for (const failure of error instanceof AggregateError ? error.errors : [error]) {
if (failure instanceof SessionWriteFailure) {
logger.error(`Not stored: ${failure.destination}: ${failure.error.reason}`);
}
}
}
process.exit(0);
});Across restarts. A new broker on the same stores seeds each token row only from a session bound to its means, and with its refresh token only when that is not empty:
| Before the restart | After it |
|---|---|
| a refused refresh discarded the refresh token R, and its '' write landed | no refresh token: the renewal strategy decides (with refreshThenLogin(), a login) |
| the same, the '' write still pending when the process ended | 'fail': you noticed — every call failed and flush() rejected before exit; 'continue': R comes back from the store |
| a new refresh token R2 landed | R2 |
| a token-only result while R was held | R — the one that provider owned |
| a session obtained under other means | discarded, not seeded (one warn line) |
A remaining limit, auth-providers' own: a process that dies between a discard and its report reaching the broker may present the stored R once after a restart.
A Credential Stays Bound to Its Identity
A token's audience, cookies' host, a refresh token's issuer: presenting a secret to a resource it was not obtained for, or for an identity it was not obtained under, is a leak. So the session store keeps two strings beside the secret, and the broker reuses a stored secret only when both are exactly what the destination's current means give.
issuedFor— the resource:serviceUrlwith the SAP client, canonical (4.x's form): scheme and host lower-cased, the port explicit (443/80), the path without a trailing/, one query parametersap-client(the means'sapClientfirst), e.g.https://my-abap.example.com:443/sap/bc/adt?sap-client=100. Both sides are canonicalised before they are compared.issuedBy— a versioned record only the broker produces, compared by exact equality and never parsed:mcp-abap-adt-binding/2;<row>;<clientId>;<uaaUrl>;<oidcIssuerUrl>;<oidcTokenEndpoint>; <oidcAuthorizationEndpoint>;<oidcDeviceAuthorizationEndpoint>;<oidcAudience>; <samlIdpSsoUrl>;<samlAcsUrl>;<samlTokenUrl>;<certUrl>;<trust>(one line; broken here for reading).
rowisauthType/grantType— orprovider/…for the token API's consumer provider. Each address field is the exact string the row hands its provider,encodeURIComponent-encoded,""when the row hands it none — never canonicalised.trustis the lower-case hex SHA-256 of the row's non-secret trust input, or"".
Which fields each row fills, and its trust input:
| Row | Address fields | Trust input (hashed, in order) |
|---|---|---|
| UAA (authorization_code, client_credentials, passcode) | clientId, uaaUrl; certUrl when the build read a certificate client | clientCertificate (the certificate client's public certificate, when read — never its key) |
| OIDC (oidc_authorization_code, device_code, password, token_exchange) | clientId, oidcIssuerUrl, oidcTokenEndpoint; oidcAuthorizationEndpoint (oidc_authorization_code), oidcDeviceAuthorizationEndpoint (device_code), oidcAudience (token_exchange); certUrl as above | oidcScopes; username (password); oidcSubjectTokenType, oidcActorTokenType (token_exchange); clientCertificate |
| saml2_pure | samlIdpSsoUrl, samlAcsUrl | samlIdpCertificates, samlIdpEntityId, samlSpEntityId, samlClockSkewMs, samlIdpInitiated |
| saml2_bearer | clientId, uaaUrl, samlIdpSsoUrl, samlAcsUrl, samlTokenUrl; certUrl as above | the SAML trust above, then clientCertificate |
| jwt / none | clientId, uaaUrl, oidcIssuerUrl as the means state them | none |
| saml / none | samlAcsUrl | none |
| the token API's consumer factory | clientId, uaaUrl of the client it was handed; an instance: none | none |
No secret takes part in either string, nor a hash of one — not the password, the client secret, a key, a subject or actor token: a hash in a session file can be checked offline against guesses. The consequence: after a restart, a session obtained under a previous password or client secret of the same user or client may seed (a revoked credential is refused by the server and renewed through the renewal strategy); within one process any secret change makes a new provider (below).
When a stored secret seeds a provider. Only the first build of a destination in a broker may
start from the store, and only when the stored issuedFor and issuedBy equal the build's
exactly and the build's binding is fully stated: its record holds the client the row
authenticates and every server address its provider sends a credential to:
| Row | Fully stated when the record holds |
|---|---|
| UAA | clientId and uaaUrl; and certUrl when the build read the certificate client |
| OIDC | clientId; the token endpoint (oidcTokenEndpoint, or oidcIssuerUrl it is discovered from); for oidc_authorization_code the authorization endpoint, for device_code the device endpoint (each explicit, or the issuer); certUrl as above |
| saml2_bearer | clientId, samlIdpSsoUrl, and the token endpoint (samlTokenUrl, or uaaUrl); certUrl as above |
| saml2_pure | samlIdpSsoUrl and samlAcsUrl |
| token_exchange, the token API's consumer provider | never |
The token, cookies and expiry then come from the same session read whose binding was checked,
and so does the refresh token the provider owns from then on. Otherwise the stored secret is not
used, refresh token included: the provider is built as with no session and obtains a new one
by its grant, and one warn line says only <destination>: the stored session secret is not
recorded as issued under the destination's current means; not used, the provider obtains a new
one — never a URI, a record or a token. A destination that can never be seeded (token_exchange,
means lacking what the record needs, or no serviceUrl) gets a debug line instead, on every
start. An OIDC destination with explicit endpoints and a client but no issuer is fully stated,
and is reused after a restart when its means are unchanged.
The none rows present a credential the broker cannot obtain again, so a mismatch is
refused, not discarded: a DestinationConfigError naming issuedFor when the stored resource
differs or is absent (or the means state no serviceUrl), and issuedBy when the stored record
is not exactly the row's.
A provider is never changed. Every call of getProvider and of the token API re-reads what
the destination's provider was built from — the means, the client, and the certificate client
when the build read it — and compares everything the build read, exactly (arrays element by
element, an absent value distinct from ''), secrets included (held in memory beside the
provider, never logged, never persisted). Unchanged: the cached provider, as it is. Anything
changed, by a single character — trust, a secret, an address, the row: a new provider,
which starts with nothing — no token, no refresh token, nothing of the old provider and nothing
of a session written under other means: it logs in. The old one is never handed out again for
that destination; whoever already holds it keeps it, and its late writes are dropped once the
new one has written. A rotated client certificate is picked up the same way: at the next call, a
new provider presenting it.
bindingOf(means, client?) — for a consumer that hands over a credential. A none
destination presents a token or cookies the broker cannot obtain; whoever writes them to the
session store writes the binding beside them. bindingOf answers exactly what getProvider
compares for those means — issuedFor and the version-2 issuedBy of the row the means state —
so a consumer never builds a record itself:
import { bindingOf } from '@mcp-abap-adt/auth-broker';
const means = await keyStore.getConnectionConfig('DEV'); // saml / none
await sessionStore.saveSession('DEV', {
sessionCookies: cookies,
refreshToken: '', // auth-stores 4 merges: clear what an earlier session left
...bindingOf(means ?? {}),
// { issuedFor: 'https://dev.example.com:443?sap-client=100',
// issuedBy: 'mcp-abap-adt-binding/2;saml/none;;;;;;;;;;;;' }
});A destination that states no jwt / saml type or no grant binds nothing ({}). A token row
built with a clientAuthentication strategy writes more than bindingOf knows (the certificate
client's certUrl and certificate), so bindingOf is for handed-over credentials.
What you meet:
- A custom
ISessionStore(a database, a secret store) must persistissuedForandissuedBybeside the secret, byte for byte, answer them fromloadSession, takerefreshToken: ''as "clear the stored one", and settle everysaveSession. One that drops them still type-checks, but the broker then never reuses its sessions: every process start is a fresh login. - The first run after upgrading to 5.0.0 reads every earlier session as unbound (see Migrating to 5.0.0): one login, or one token request, per token destination.
- Changing a destination's URL, SAP client, client, grant, any server address or any trust
value costs one fresh login — even a cosmetic change (a trailing
/, a case change, a port written out): addresses are compared exactly. - A headless process whose strategy refuses logins gets Oops ("login required") from
prepare()/rejected()on a mismatch, instead of presenting a foreign secret; log in again with the CLI.
Cancellation: signal
const session = new AbortController(); // one per connector session
const provider = await broker.getProvider('TRIAL', { signal: session.signal });
const token = await broker.getToken('TRIAL', { signal: AbortSignal.timeout(60_000) });
const refresher = broker.createTokenRefresher('TRIAL', { signal: session.signal });
await broker.flush({ signal: AbortSignal.timeout(10_000) });
session.abort(); // the session closed: a login its provider started now is abortedEvery call takes BrokerCallOptions, { readonly signal?: AbortSignal | undefined }. A signal says "this caller no longer needs the answer". Its abort releases that caller alone
from every wait it has — the store reads, the shared build, its write — with auth-errors'
aborted failure (interactive-login, outcome: 'aborted', "the authorization was aborted"),
never with success; the work it waited on runs on for the others. The waiter rules are
auth-errors' sharedAttempt; the broker implements none of its own.
- Concurrent callers of one destination share one resolution, per path (below). One caller's abort rejects only its promise. When every caller has aborted, the attempt leaves the slot at once: a caller arriving meanwhile starts a fresh one, and a build that completes after its attempt was aborted is never cached, never handed out, and writes nothing.
getProvider's signal is attached to the provider it answers — built or from the cache — when that provider has parties: every token provider and the SNC provider. A login that provider starts later in a moment (rejected()above all) is aborted once every session holding it has aborted its signal.getProviderwithout a signal attaches nothing. Tie the signal to the connector's session: abort it when the session closes.- The token API never attaches.
getToken/refreshTokenpass the call's signal to the provider'sgetTokens({ signal })/refreshTokens({ signal }); a token call can neither keep a later moment's login alive nor bound it.createTokenRefresher(destination, { signal })makes every call of the refresher a waiter with that signal. - A cached provider after every caller has gone stays cached. Its parties were released by their aborts, so a moment's login it starts later runs unbounded — the next caller that gave no signal can log in on it; one that gives a signal is attached again.
- The
clientAuthenticationstrategy gets the build's attempt asClientAuthenticationContext.signal: it aborts when every caller waiting on the build has gone. Store reads take no signal (the store contract has none): the caller is released at once, the read completes on its own and its result is dropped. - No bound of the broker's own. The broker sets no timeout, adds no signal of its own and
has no timer; nothing bounds anybody's wait. A caller that wants a bound passes
AbortSignal.timeout(ms).
How the Client Authenticates: clientAuthentication
Every grant whose client authenticates to the authorization server — the UAA grants, the OIDC
grants and saml2_bearer (ClientAuthenticationGrant) — sends the client's secret, unless you
tell the broker otherwise. An XSUAA service key created with {"credential-type": "x509"} holds
no secret: it holds a client certificate, its private key and the mTLS host the certificate is
presented to (certurl). How the client authenticates is your choice, stated as a strategy —
the broker never infers it from a key's shape and has no default:
import { AuthBroker, fromServiceKeyCertificate } from '@mcp-abap-adt/auth-broker';
import { refreshThenLogin } from '@mcp-abap-adt/auth-providers';
import { XsuaaServiceKeyStore, XsuaaSessionStore } from '@mcp-abap-adt/auth-stores';
const x509Broker = new AuthBroker({
serviceKeyStore: new XsuaaServiceKeyStore('/path/to/keys', {
grantType: 'client_credentials',
}),
sessionStore: new XsuaaSessionStore('/path/to/sessions'),
renewal: () => refreshThenLogin(),
onWriteFailure: 'fail',
// The key's certificate, presented at <certurl>/oauth/token.
clientAuthentication: fromServiceKeyCertificate(),
});
const certificateProvider = await x509Broker.getProvider('mcp');The strategy is (context) => Promise<IClientAuthentication> (ClientAuthenticationStrategy).
The broker calls it once per build of a destination's provider, with a
ClientAuthenticationContext:
destinationandgrant(aClientAuthenticationGrant);client— the secret client the key store'sgetAuthorizationConfiganswered, asuaaUrl,uaaClientIdanduaaClientSecretonly — never a refresh token — ornull(an x509 key answersnullthere);readCertificate()— the key store's certificate client (IClientCertificate:uaaUrl,clientId,certificate,key,certUrl), read only when called, at most once per build;nullwhen the store holds none or implements nogetClientCertificate;signal— the build's attempt (see Cancellation).
Its answer goes to the provider as clientAuthentication, and no client secret goes with it
(auth-providers 6 refuses both). A given strategy always answers or throws: there is no
"nothing" answer, so an explicit choice never falls back to the secret. saml2_pure, the none
rows, basic and snc authenticate no client and never call it.
The two shipped factories each fail closed:
| Factory | Answers | Refuses |
|---|---|---|
| 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" |
| 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" |
Composing them is yours. A fallback, and its order, is your statement, never the broker's.
Branch on what the key holds (readCertificate() is memoised, so the factory reads the same
answer); do not catch a factory's refusal and fall back — that would also swallow an expired,
incomplete or unreadable certificate and send the secret instead:
import {
type ClientAuthenticationStrategy,
fromServiceKeyCertificate,
fromServiceKeySecret,
} from '@mcp-abap-adt/auth-broker';
// The certificate when the key holds one, else the secret in a Basic header.
const certificateElseSecret: ClientAuthenticationStrategy = async (context) =>
(await context.readCertificate())
? fromServiceKeyCertificate()(context)
: fromServiceKeySecret({ encoding: 'raw' })(context);
// The certificate for client_credentials only; every other grant its secret.
const byGrant: ClientAuthenticationStrategy = (context) =>
context.grant === 'client_credentials'
? fromServiceKeyCertificate()(context)
: fromServiceKeySecret({ encoding: 'raw' })(context);Where the certificate comes from — IServiceKeyStore.getClientCertificate?
(@mcp-abap-adt/interfaces-auth-broker, optional), which auth-stores implements:
XsuaaServiceKeyStore— a key (bare, or wrapped incredentials) carryingurl,clientid,certificate,keyandcerturland noclientsecretis an x509 key:getAuthorizationConfiganswersnull,getClientCertificatethe certificate client. A key carrying both a secret and a complete certificate offers both, and your strategy picks.AbapServiceKeyStoreholds no certificate client.EnvDestinationStore— three means variables, each a path or a URL, never PEM:SAP_UAA_CLIENT_CERT_PATH,SAP_UAA_CLIENT_KEY_PATH,SAP_UAA_CERT_URL(XSUAA_UAA_CLIENT_CERT_PATH, … withXSUAA_DESTINATION_VARS), besideSAP_UAA_URL/SAP_UAA_CLIENT_IDand withoutSAP_UAA_CLIENT_SECRET. See auth-stores' README.
# A certificate destination — EnvDestinationStore (ABAP_DESTINATION_VARS)
SAP_AUTH_TYPE=jwt
SAP_GRANT_TYPE=client_credentials
SAP_URL=https://your-system.abap.us10.hana.ondemand.com
SAP_UAA_URL=https://your-account.authentication.us10.hana.ondemand.com
SAP_UAA_CLIENT_ID=sb-your-app!t12345
SAP_UAA_CLIENT_CERT_PATH=/home/you/keys/client.crt
SAP_UAA_CLIENT_KEY_PATH=/home/you/keys/client.key
SAP_UAA_CERT_URL=https://your-account.authentication.cert.us10.hana.ondemand.comWithout a strategy the client secret goes to the provider and nothing certificate-related is
read. A client row whose key store answers no client, or one without a client id, is refused
naming the fields, and its message adds a certificate client needs a clientAuthentication
strategy.
With a strategy, what the row requires is its client's identity — uaaUrl and
uaaClientId (the OIDC rows: uaaClientId) — taken from the secret client when the key store
has one, else from the certificate client (uaaUrl, clientId); the secret is no longer
required. The record names that identity, and — when the build read the certificate client —
its certUrl and, in the trust digest, its certificate. On this path a resource neither the
means nor the stored session states matches (an XSUAA destination without a service URL reuses
its own session); a resource stated on one side only never matches.
A strategy that fails carries nothing out but what auth-errors admits. Whatever the
strategy, a store it reads or the certificate check throws — and an answer that is no
IClientAuthentication — becomes a DestinationConfigError with missingFields:
['clientAuthentication'], before any provider exists:
| Words (after Destination "<name>": ) | When |
|---|---|
| the clientAuthentication strategy refused: the destination has no client certificate | fromServiceKeyCertificate(), no certificate client |
| the clientAuthentication strategy refused: the destination has no client secret | fromServiceKeySecret(), no secret client or an empty secret |
| the clientAuthentication strategy failed: <reason> | anything else; error carries what was thrown as auth-errors reads it — for an unusable certificate, auth-providers' client-certificate failure, whose reason is the client certificate is incomplete, … could not be used or … has expired (its hint in error.hint); for your own error, its classification only |
| the clientAuthentication strategy answered no client authentication | the answer has no authenticate function |
| the client certificate could not be read | the store failed reading the certificate client for the row's identity |
| the client certificate the key store answered holds no certificate | a certificate client whose certificate is not a string |
A certificate, a key or a file's content never reaches a log line, a refusal, a
DestinationConfigError, a thrown message or the session store.
What is measured. client_credentials with an x509 XSUAA service key, against XSUAA on a BTP
trial (2026-10-05, on 4.1.0: fromServiceKeyCertificate() + XsuaaServiceKeyStore, through
getProvider and the token API, and through the CLI) — see Testing. Not measured:
authorization_code and passcode over x509, the OIDC grants and saml2_bearer with a
strategy, and ABAP environment service keys with x509.
Headless Processes (No Browser)
Whether a login may happen is the renewal strategy's and the interactive strategy's, not a
broker switch. A process nobody is watching (an MCP server on stdio, a CI job) gives the broker
renewal: () => refreshOnly() — never a login — or strategies that refuse:
import { AuthBroker } from '@mcp-abap-adt/auth-broker';
import { refreshThenLogin } from '@mcp-abap-adt/auth-providers';
class LoginRequiredError extends Error {}
const refuseLogin = {
authorize: async (): Promise<never> => {
throw new LoginRequiredError('Run mcp-auth to log in');
},
};
const headless = new AuthBroker({
serviceKeyStore: myKeyStore,
sessionStore: mySessionStore,
renewal: () => refreshThenLogin(),
onWriteFailure: 'continue',
authorization: () => refuseLogin,
oidcAuthorization: () => refuseLogin,
deviceCodePresenter: () => ({
present: async () => {
throw new LoginRequiredError('No one to show a device code to');
},
}),
});The provider runs on its stored secret and refresh token; when a login is needed, prepare() /
rejected() answer Oops and getToken() rejects with the provider's AuthProviderFailure —
fixed wording, never the message of what your strategy threw — and nothing is written. A cached token that is still valid, or a refresh token the
server accepts, never reaches the strategy.
Custom Callback Port
How a login is conducted — including which port the local OAuth2 callback listens on — is the
strategy you return from authorization, not a broker option. browserCallbackStrategy from
@mcp-abap-adt/auth-providers listens on 61001 by default, clear of the range application
servers and proxies typically use (3001 / 3333). Pass port to match a redirect URI
registered at the identity provider:
import type { AuthBrokerConfig } from '@mcp-abap-adt/auth-broker';
import { browserCallbackStrategy, linuxDefaultBrowser } from '@mcp-abap-adt/auth-providers';
const onPort4001: AuthBrokerConfig['authorization'] = () =>
browserCallbackStrategy({ browser: linuxDefaultBrowser(), port: 4001 });Every listener is loopback-only; a user whose browser is on another machine tunnels the port
(ssh -L).
Getting Tokens: the Token API
// The provider's current token: cached while valid, else renewed as its strategy says.
const token = await broker.getToken('TRIAL');
// A new token, never the cached one — after the server refused the token (401).
const newToken = await broker.refreshToken('TRIAL', { signal });Two paths, two providers. The token API answers from one of two paths, each resolved, cached and bound on its own:
- No
provideroption — the row path: the destination's own provider, the very onegetProviderhands out (the same cache; never throughgetProvideritself). A connector and the token API in one process share one token, one refresh token and one renewal; the token API writes nothing itself (the provider's persistence does, and a cache hit writes nothing). The destination must state a grant that obtains a token; anonedestination is aDestinationConfigErrornamingprovider. - A
provideroption — the consumer path: yours. An instance is used as given, for every destination; a factory is called once per destination and again whenever what it was handed changes (its new provider starting with nothing). Every answer — cache hits included — is written through the same write queue, raced against the call's signal, with the binding fixed when the provider was taken into use (issuedForthe URL with the SAP client,issuedBytheprovider/…record): the result is authoritative — a result without a refresh token writesrefreshToken: ''.
import { AuthBroker, type TokenProviderFactory } from '@mcp-abap-adt/auth-broker';
import { ClientCredentialsProvider, refreshThenLogin } from '@mcp-abap-adt/auth-providers';
const clientCredentials: TokenProviderFactory = (destination, authConfig) => {
if (!authConfig) throw new Error(`No client for ${destination}`);
return new ClientCredentialsProvider({
uaaUrl: authConfig.uaaUrl,
clientId: authConfig.uaaClientId,
clientSecret: authConfig.uaaClientSecret,
renewal: refreshThenLogin(), // yours: the broker gives a provider of yours none
// No persistence: the token API writes every answer of this provider itself.
});
};
const tokenBroker = new AuthBroker({
serviceKeyStore: myKeyStore,
sessionStore: mySessionStore,
provider: clientCredentials,
onWriteFailure: 'fail', // the token API writes every answer
});The factory is handed the means and the client, never a stored secret. authConfig is the
client (uaaUrl, uaaClientId, uaaClientSecret — the session store's when it answers one,
else the key store's; refreshToken present as a key, never a value), or null when no store
has a client. connConfig is serviceUrl (the session's, else the key store's; neither is an
error before the factory is called), sapClient, language, authType, grantType — no
token, cookies, expiry, refresh token or binding. The broker cannot know what your factory
composes from what it is handed, so the consumer path is never seeded from the store: a
provider of yours that must resume after a restart composes that itself — its own seed and its
own persistence (refreshStatePersistence over a store of yours) — or use the row path, whose
providers the broker seeds from a matching record.
An instance after the means changed cannot be rebuilt, and the identity of the credential it
holds is unknown to the broker: once the identity the instance was first used for changes, the
token API refuses the destination — DestinationConfigError(['provider']), "the destination's
means changed since the provider instance was first used for it" — until a new broker.
A destination stated basic or snc has no token API: the token API reads the
destination's authType first and throws DestinationConfigError naming authType before any
provider is asked. A key store that states no authType, or no key store, is served by a
provider of yours as before.
Two token sources, with a provider option. getProvider never uses the provider option.
A process that gives the broker a provider and also calls getProvider for the same
destination has two providers for it — each with its own token, renewal and binding record,
both writing the same session. Use one path per destination; or, to hand your own provider to a
connector, wrap the token API: TokenAuthProvider.from(broker.createTokenRefresher('TRIAL'))
(@mcp-abap-adt/auth-providers), whose every renewal the token API writes.
Creating a Token Refresher for DI
createTokenRefresher(destination, { signal? }) returns an ITokenRefresher (from
@mcp-abap-adt/interfaces-auth) bound to one destination — getToken() is
broker.getToken(destination, { signal }), refreshToken() is
broker.refreshToken(destination, { signal }) — for a connection of your own that asks for a
token per request and for a new one after a 401.
const tokenRefresher = broker.createTokenRefresher('TRIAL', { signal: session.signal });
const current = await tokenRefresher.getToken();
// …the server answered 401:
const renewed = await tokenRefresher.refreshToken();A @mcp-abap-adt/connection 14 connector takes an IAuthProvider instead: give it await
broker.getProvider('TRIAL', { signal }).
Errors
A provider's failure passes as the same object. Whatever a provider throws from
getTokens() / refreshTokens() — an AuthProviderFailure of @mcp-abap-adt/auth-errors —
getToken() / refreshToken() rethrow unchanged, so its kind, facts, words and diagnostics are
the provider's; the moments of a provider getProvider hands out are never wrapped. Read a
failure with auth-errors, never by class or message:
import { isDestinationConfigError } from '@mcp-abap-adt/auth-broker';
import { isAuthProviderFailure, readFailure } from '@mcp-abap-adt/auth-errors';
try {
await broker.getToken('TRIAL', { signal });
} catch (error) {
if (isDestinationConfigError(error)) {
// Names only: error.missingFields, e.g. ['renewal'] or ['uaaClientSecret'].
logger.error(`${error.destination} lacks: ${error.missingFields.join(', ')}`);
} else if (isAuthProviderFailure(error)) {
const failure = readFailure(error, 'token-source');
// failure.kind: 'interactive-login', 'credential-refused', 'request-failed', …
logger.error(failure.hint ? `${failure.reason} — ${failure.hint}` : failure.reason);
}
throw error;
}readFailure(thrown, operation) gives the same kind and facts for a failure of another
installed copy of auth-errors (its diagnostics dropped); matchKind(failure, handlers) switches
over every kind. auth-providers' Migrating to 6.0.0 maps each 5.x error class to its kind.
The failures the broker makes itself are AuthProviderFailures minted by auth-errors, no
free words of its own:
| When | Kind and facts | Words |
|---|---|---|
| a caller's signal aborted | interactive-login, outcome: 'aborted' | the authorization was aborted |
| a session write did not land, under 'fail' (the consumer path; the row path's provider answers the same) | unknown, operation: 'persisting-tokens', an allowlisted code | persisting the tokens failed (unknown error[, CODE]) |
| a provider answered no token | request-failed, operation: 'token-source', problem: 'no-access-token' | the token source returned no access_token |
DestinationConfigError — a destination that lacks what its type needs, thrown by
getProvider (and the token API) before any provider is asked. It carries name:
'DestinationConfigError', code: 'DESTINATION_CONFIG', destination and missingFields —
store field or broker option names only, never a value — and, when a provider's or a strategy's
failure caused the refusal, error: that failure as auth-errors read it (IAuthProviderError:
kind, facts, rendered reason and hint, admitted diagnostics — no cause, no message of any
thrown value). Its message is Destination "<destination>": <reason> (<fields>), the reason
followed by : <error.reason> when error is present; error.hint is not in the message.
Recognise it with isDestinationConfigError(value) — structural, so a JSON copy and one of
another installed copy of this package answer true — rather than instanceof.
| Case | missingFields |
|---|---|
| no serviceKeyStore option (getProvider) | serviceKeyStore |
| the token API with neither a provider nor a serviceKeyStore option | provider, serviceKeyStore |
| the token API on a destination stated basic or snc | authType |
| the token API without a provider option, on a jwt / none or saml / none destination | provider |
| a token row built with no renewal option | renewal (beside every other missing field) |
| a destination that writes a secret (a token row, or the token API with a provider) and no onWriteFailure option ('fail' / 'continue') | onWriteFailure |
| the renewal option threw (error carried) | renewal |
| a provider's constructor refused the configuration the row gave it (error carried) | the store fields its configuration facts name, or none |
| the token API with an instance provider after the destination's identity changed | provider |
| the key store has no means for the destination — whatever the session holds | authType |
| no authType, '', or one that is not basic, jwt, saml, snc | authType |
| jwt / saml without grantType, '', or a pair outside the table | grantType |
| basic without user or password ('' counts as missing) | username, password — each that is missing |
| snc without sncPartnerName | sncPartnerName |
| snc whose settings the provider refuses (error carried) | the store field its facts name, e.g. sncQop; for another kind, the SNC fields the means state |
| jwt / none without a token in the session | authorizationToken |
| saml / none without cookies in the session | sessionCookies |
| jwt / none, saml / none whose stored issuedFor is not the destination's resource, or is absent, or the means state no serviceUrl | issuedFor |
| jwt / none, saml / none whose stored issuedBy is not exactly the row's record (a 4.x binding included) | issuedBy |
| 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 |
| authorization_code / passcode without the authorization option | authorization |
| an OIDC grant without its client's id in the key store | uaaClientId |
| an OIDC grant with neither oidcIssuerUrl nor every endpoint its row reads | oidcIssuerUrl, and each endpoint missing |
| oidcScopes that is not a list of strings; oidcActorTokenType that is not a string | oidcScopes, oidcActorTokenType |
| password without user or password | username, password — each that is missing |
| token_exchange without its subject | oidcSubjectToken, oidcSubjectTokenType — each that is missing |
| oidc_authorization_code without the oidcAuthorization option | oidcAuthorization |
| device_code without the deviceCodePresenter option | deviceCodePresenter |
| a SAML grant without its trust ('' and an empty certificate list count as missing) | samlIdpSsoUrl, samlSpEntityId, samlIdpEntityId, samlIdpCertificates — each that is missing |
| samlIdpInitiated that is not a boolean | samlIdpInitiated |
| saml2_bearer without its client | uaaUrl, uaaClientId — each that is missing |
| 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 |
| the clientAuthentication strategy threw, refused, or answered no client authentication; the certificate client could not be read | clientAuthentication (see How the Client Authenticates) |
| the token API with a clientAuthentication strategy and a factory, on a destination that states no jwt / saml type | authType, grantType |
| the token API's factory threw beside a clientAuthentication strategy | provider, clientAuthentication |
| a SAML grant without its collaborators | authorization, samlCookies (saml2_pure), assertionReplayStore — each that is missing |
| a certificate the validator cannot read (error carried) | samlIdpCertificates |
| a samlClockSkewMs that is not a whole, non-negative number | samlClockSkewMs |
| a store answered a field in a shape the broker cannot take (a function, a getter that throws) | that field |
Kept as the broker's own configuration words, none copying a provider's: the reason of every row
above, "the destination has no client certificate / secret", and the hint a certificate client
needs a clientAuthentication strategy.
What is not an auth failure:
- A store's read failure (anything but
FILE_NOT_FOUND, which is absence) reaches the caller as the store raised it — your collaborator's error, returned to you, also through the shared resolution. A store that answersnull, or fails withFILE_NOT_FOUND(logged at debug), means "nothing here". - The constructor's argument checks (
AuthBroker: sessionStore is required, …) are plainErrors naming option names: a programming error. - The token API with a factory and no
serviceUrlin either store:Session for destination "<name>" is missing required field 'serviceUrl', a plainError, before the factory is called. flush()rejects with theAggregateErrorofSessionWriteFailures above.
Logging and authDebug
Pass an ILogger (@mcp-abap-adt/interfaces-utils) as the constructor's second argument;
without one nothing is logged. Every line goes through a guard: a logger that throws or rejects
changes no outcome. The broker never writes to stdout.
What is logged: debug — the broker's initialization (whether a key store is given; the
provider option: none, factory or instance), each provider build ({ authType, grant,
seeded }), the token API's method, a replaced provider's write dropped, a destination whose
means never let a stored secret be used; info — each session secret saved ({ credential:
'token' | 'cookies', hasRefreshToken, expiresAt }), a refresh token cleared; warn — a stored
secret not recorded under the current means and not used, a session write that failed (Session
write for <destination> failed; it stays pending until the destination's next write or
flush(), with the failure's logFields), a write not made because the destination is now
basic / snc. The destination name is the only free value.
What is never logged: any part of a token, refresh token, password or secret, a store's or a
provider's message, a URL, a client id, state.
After a login, at info, your logger is called with the message [AuthBroker] Session secret
saved for TRIAL and the meta { credential: 'token', hasRefreshToken: true, expiresAt:
1790000000000 }.
authDebug. authDebug: true is passed to every token provider the broker builds; on only
for true itself — absent, false, 'true' or 1 is off — and never read from the
environment (no DEBUG_* variable changes anything). The broker's logger is the providers'
logger, so the provider's one debug line for a refused token request lands in yours. With
authDebug that line names the request's secrets in the provider's prepared form (the first and
last four characters around a length marker) and never the server's text; without it no line
carries a secret or server text. A provider of yours — an instance or what a factory builds —
keeps its own setting.
Configuration
Environment Variables
The library reads no environment variable: the stores take their directories from their constructors, the providers their settings from th
