使用说明
@mcp-abap-adt/auth-stores
Stores for MCP ABAP ADT auth-broker - BTP, ABAP, and XSUAA implementations.
This package provides file-based and in-memory stores for service keys and sessions used by the @mcp-abap-adt/auth-broker package.
Installation
npm install @mcp-abap-adt/auth-stores
Requires Node.js 22, 24 or 26. From 4.0.0 it is built on @mcp-abap-adt/interfaces-auth 7, @mcp-abap-adt/interfaces-auth-sap 3 and @mcp-abap-adt/interfaces-auth-broker 1.3: use it with the auth chain on those contracts (@mcp-abap-adt/auth-providers 6 and the broker release built on them) — see the CHANGELOG's migration note.
Overview
This package implements the IServiceKeyStore and ISessionStore contracts from @mcp-abap-adt/interfaces-auth-broker. Since 3.0.0 the two stores are split by the role of the data:
| Store | Holds | Implementations here |
|---|---|---|
IServiceKeyStore — the means: what is used to obtain a session secret |
the client (uaaUrl, uaaClientId, uaaClientSecret), or a client certificate (getClientCertificate, 3.3.0); authType, grantType; basic's username / password; the snc*, oidc* and saml* settings; serviceUrl, sapClient, language |
AbapServiceKeyStore, XsuaaServiceKeyStore (SAP service key JSON), EnvDestinationStore (<dir>/<destination>.env) |
ISessionStore — the secret that authorizes within a session |
authorizationToken or sessionCookies, their expiresAt, refreshToken — and what the credential is bound to, issuedFor and issuedBy (3.1.0) |
AbapSessionStore, XsuaaSessionStore, EnvFileSessionStore (files), SafeAbapSessionStore, SafeXsuaaSessionStore (memory) |
A key store never answers a secret, and a session store refuses to hold means. basic and snc destinations obtain no session secret: their session holds nothing.
- File Handlers: utility classes for JSON and ENV files (exported for consumers; the stores do their own file I/O)
Responsibilities and Design Principles
Core Development Principle
Interface-Only Communication: This package follows a fundamental development principle: all interactions with external dependencies happen ONLY through interfaces. The code knows NOTHING beyond what is defined in the interfaces.
This means:
- Does not know about concrete implementation classes from other packages
- Does not know about internal data structures or methods not defined in interfaces
- Does not make assumptions about implementation behavior beyond interface contracts
- Does not access properties or methods not explicitly defined in interfaces
This principle ensures:
- Loose coupling: Stores are decoupled from concrete implementations in other packages
- Flexibility: New implementations can be added without modifying stores
- Testability: Easy to mock dependencies for testing
- Maintainability: Changes to implementations don't affect stores
Package Responsibilities
This package is responsible for:
- Implementing storage interfaces: Provides concrete implementations of
IServiceKeyStore(the means) andISessionStore(the session secret) defined in@mcp-abap-adt/interfaces-auth-broker - File I/O operations: Handles reading and writing service key JSON files and session
.envfiles - Data format conversion: Converts between interface types (
IConfig,IConnectionConfig,IAuthorizationConfig) and internal storage formats - Platform-specific handling: Provides different store implementations for ABAP, BTP, and XSUAA with their specific data formats
What This Package Does
- Implements interfaces: Provides concrete implementations of
IServiceKeyStoreandISessionStore - Handles file operations: Reads/writes JSON and
.envfiles using atomic operations - Manages data formats: Converts between interface types and
.env/ JSON key names - Provides utilities: File handlers (
JsonFileHandler,EnvFileHandler) for safe file operations. The.envstores do not useEnvFileHandler: they read with dotenv and rewrite only their own keys of a file, keeping every other line
What This Package Does NOT Do
- Does NOT implement authentication logic: Token acquisition and OAuth2 flows are handled by
@mcp-abap-adt/auth-providers - Does NOT orchestrate authentication: Token lifecycle management is handled by
@mcp-abap-adt/auth-broker - Does NOT know about token validation: Token validation logic is not part of this package
- Does NOT interact with external services: All HTTP requests and OAuth flows are handled by other packages
External Dependencies
This package interacts with external packages ONLY through interfaces:
@mcp-abap-adt/interfaces-auth-broker(^1.3.0):IServiceKeyStore,ISessionStore,IConfig,IConnectionConfig,DestinationGrant— the broker's port: the destination and the stores that hold it. 1.0.0 carriesgrantType,expiresAtand theoidc*/saml*fields; 1.1.0 addsissuedFor/issuedBy; 1.2.0 addsgetClientCertificate@mcp-abap-adt/interfaces-auth-sap(^3.3.0):IAuthorizationConfig— the UAA client@mcp-abap-adt/interfaces-auth(^7.5.0):STORE_ERROR_CODESandStoreErrorCode— the failure vocabulary, which means the same off SAP@mcp-abap-adt/interfaces-utils(^1.1.1):ILoggerdotenv(^18.0.4): parses.envfiles- Not
@mcp-abap-adt/interfaces: that facade is deleted as of its 52.0.0. npm still serves 51.0.0 to anyone pinned to it, with every symbol re-exported and deprecated, and nothing further ships there — a consumer takes the package that declares the name - No direct dependencies on other implementation packages: all interactions happen through those contracts
Store Types
Key stores — the means
AbapServiceKeyStore— reads ABAP service keys ({destination}.json, nesteduaaobject)XsuaaServiceKeyStore(aliasBtpServiceKeyStore) — reads XSUAA service keys (direct format, orcf service-keyoutput with acredentialswrapper), with a client secret or, from 3.3.0, an x509 client certificate (see Client certificates)EnvDestinationStore— a destination's means in<directory>/<destination>.env, with a write method of its own; optionally falls back to another key store field by field
A SAP service key holds an OAuth client and nothing else, so both service key stores answer authType: 'jwt' from getConnectionConfig. A service key cannot state which grant a destination uses (the grants a client may use are declared on the XSUAA instance, and a client permitted several serves all of them), so the grant comes from whoever builds the store (3.1.0):
new AbapServiceKeyStore(dir, { grantType: 'authorization_code', log });
new XsuaaServiceKeyStore(dir, { grantType: 'client_credentials' });
new AbapServiceKeyStore(dir, log); // the 3.0.0 form: no grant answered
grantTypegiven is answered bygetConnectionConfigandgetServiceKeyfor every destination that has a key; without it nograntTypeis answered, as in 3.0.0. Nothing is inferred. Alternatively, state the grant in anEnvDestinationStore— on its own, or with the service key store as its fallback.- The second argument is
{ grantType?, log? }or, as in 3.0.0, the logger itself (an object with a logger'sdebug/info/warn/error—consoleincluded). Anything else is aTypeErrorat construction, naming what was wrong and never a value: a string or another non-object, an option it does not take, a grant it does not know. - No
serviceUrloption. The resource URL is means of the destination, not of the key: state it in anEnvDestinationStore(setDestination(name, { serviceUrl }),XSUAA_MCP_URLwithXSUAA_DESTINATION_VARS) with the key store as its fallback. An XSUAA key's ownurlis still read as before (aurlwithoutauthenticationin it). - They answer no token (2.x answered
authorizationToken: '') and neverissuedFor/issuedBy.
Client certificates (3.3.0)
An XSUAA instance whose oauth2-configuration.credential-types includes x509 issues keys created with {"credential-type": "x509"}: such a key carries url, clientid, certificate (PEM, possibly a chain, leaf first), key (PEM) and certurl (the mTLS host of the authorization server), and either no clientsecret (as our BTP trial issued it, measured 2026-10-04) or a clientsecret too (a shape SAP documents for x509 credentials — SAP Cloud SDK, mTLS — where the certificate is used in place of the secret). IServiceKeyStore.getClientCertificate(destination) (@mcp-abap-adt/interfaces-auth-broker 1.2.0, optional) answers the certificate client:
const store = new XsuaaServiceKeyStore('/path/to/service-keys');
await store.getAuthorizationConfig('X509'); // null — never a public client
await store.getClientCertificate('X509');
// { uaaUrl: key.url, clientId: key.clientid, certificate, key, certUrl: key.certurl }
- An x509 key without a secret:
getAuthorizationConfiganswersnull. Its missing secret would otherwise read as a public client to a consumer that does not know certificates; the whole certificate client comes only fromgetClientCertificate. - An x509 key with a secret offers both clients.
getAuthorizationConfigandgetServiceKeyanswer the secret client exactly as 3.2.0 did, andgetClientCertificatethe certificate client. The store does not choose —credential-typeis not read; SAP says the certificate is used in place of the secret for such a binding, and the consumer chooses (the broker's client-authentication strategy). - A key with neither
certificatenorkeyanswersgetClientCertificatewithnulland everything else exactly as before — also when it names acerturl. - Part of a certificate client only — one of
certificate/key, or a missingcerturl,urlorclientid— is refused bygetClientCertificateasincomplete(ClientCertificateError), naming the missing fields;getAuthorizationConfiganswers as in 3.2.0 (the secret client, ornullwithout a secret). - The PEM is answered as given — line endings (CRLF included) and chains untouched; the authentication provider checks it. It is never logged, and a refusal names the key's fields, never a value.
getConnectionConfiganswers an x509 key exactly as a secret key (serviceUrl,authType: 'jwt',sapClient,language, the statedgrantType).AbapServiceKeyStoreis unchanged: an ABAP service key carries no client certificate, and the store has nogetClientCertificate.- Which authentication the client uses is the consumer's choice (the broker's client-authentication strategy); the store answers data only. A certificate never reaches a session store.
Session stores — the secret
File-based (.env files):
AbapSessionStore(aliasSamlSessionStore) —SAP_JWT_TOKENorSAP_SESSION_COOKIES_B64,SAP_EXPIRES_AT,SAP_REFRESH_TOKEN,SAP_ISSUED_FOR,SAP_ISSUED_BYXsuaaSessionStore(aliasBtpSessionStore) —XSUAA_JWT_TOKEN,XSUAA_EXPIRES_AT,XSUAA_REFRESH_TOKEN,XSUAA_ISSUED_FOR,XSUAA_ISSUED_BYEnvFileSessionStore— theSAP_*secret keys of one given file (--env /path/to/.env), whatever the destination
In-memory (non-persistent):
SafeAbapSessionStore(aliasSafeSamlSessionStore)SafeXsuaaSessionStore(aliasSafeBtpSessionStore)
Every session store follows the same rules:
- The secret alone.
saveSessionandsetConnectionConfigtakeauthorizationToken,sessionCookies,expiresAt(epoch ms),refreshToken, and what the credential is bound to,issuedForandissuedBy(see The binding). A write carrying any other field —serviceUrl,authType,grantType,username,password, thesnc*,oidc*andsaml*fields,sapClient,language,uaaUrl/uaaClientId/uaaClientSecret, or a field no store knows — is refused with aRefusedFieldsError(codeINVALID_CONFIG) whose message andfieldsname the fields, never a value. A field given asundefinedis not carried. - No serviceUrl. A session needs none, and none is answered.
- No client.
setAuthorizationConfigalways refuses (IAuthorizationConfigis the client, which is means);getAuthorizationConfiganswersnull. A refresh token is written throughsaveSession, and answered byloadSession. - One secret kind at a time. Writing a token clears stored cookies, and writing cookies clears the token.
expiresAtis written and cleared with its credential: a new credential written withoutexpiresAtdoes not keep the old one's. A credential given as''clears it, with itsexpiresAt; clearing the kind not held (authorizationToken: ''while cookies are stored) changes nothing.expiresAtmust be a non-negative whole number of epoch milliseconds — what the file stores read back — anything else is refused. - Clearing the refresh token:
refreshToken: ''. This is the one operation that removes a stored refresh token. A write withrefreshToken: ''removes it — whatever else the write carries, a new access token included; a write withrefreshTokenomitted orundefinedkeeps the one stored; a write with another refresh token replaces it. So a consumer that obtained a new access token without a refresh token, where one was stored, writesrefreshToken: ''beside the new token if the old refresh token must not be used again; leaving the field out keeps it. Every session store here — files and memory — does the same. - The XSUAA stores hold a token. They refuse cookies, and a write that would leave the session without a token.
- What is answered.
loadSession: the secret fields present — withissuedFor/issuedBywhile a credential is held — ornullwhen there are none.getConnectionConfig: the same without the refresh token, ornull. A file store answers only a record of its own format, and refuses any other with aStorageError(record-format; see The store validates its record). - A file store touches only its own keys. A write sets or removes the secret keys and leaves every other line of the file — keys, comments, blank lines — byte for byte.
deleteSessionremoves the secret keys, and the file only when no key is left. - The binding — see below.
- Constructors:
AbapSessionStore(directory, log?),XsuaaSessionStore(directory, log?),EnvFileSessionStore(envFilePath, log?),SafeAbapSessionStore(log?),SafeXsuaaSessionStore(log?). File stores create their directory if it is missing. A string where the logger goes — 2.x'sdefaultServiceUrl, passed by a JavaScript caller — throws aTypeErrorat construction saying so.
The binding: issuedFor and issuedBy (3.1.0)
A session store keeps, beside the credential, two strings (IConnectionConfig, @mcp-abap-adt/interfaces-auth-broker 1.1.0): issuedFor, the URI of the resource the credential was obtained for, and issuedBy, the URI of who issued it and to which client. The broker (@mcp-abap-adt/auth-broker 4) writes both with every secret and uses a stored secret only when both equal what the destination's means give — so a secret is never presented to another resource, and one from another issuer or client is never used in place of this one. The store keeps the strings as given: it neither canonicalises nor judges them; the broker canonicalises both sides before it compares.
They follow the credential, extending the rules above:
- A credential written (a token or cookies, including one replacing the other kind) takes the
issuedFor/issuedByits write gives; one the write leaves out — absent or''— is cleared. A new credential never inherits the old one's binding. - No credential written (only
expiresAt,refreshToken, or the binding itself): a binding field given sets it,''clears it, absent (undefined) keeps it. - No credential held after the write (none written yet, or
''cleared it, ordeleteSession): neither is kept, and neither is answered. - A value that is not a string is refused with an
InvalidConfigErrornaming the field. - File keys:
SAP_ISSUED_FOR/SAP_ISSUED_BY(AbapSessionStore,EnvFileSessionStore),XSUAA_ISSUED_FOR/XSUAA_ISSUED_BY(XsuaaSessionStore); fields in the in-memory stores. A file store writes both keys with every credential, empty when there is none, and removes them with the credential. - Key stores never answer them, and
EnvDestinationStore.setDestinationrefuses them like any secret field.
A custom ISessionStore (a database, a message log, a secret store) must persist both fields beside the secret. One that drops them still type-checks, but the broker then never uses its sessions — every process start logs in afresh, a browser each time for an interactive grant.
The store validates its record (5.0.0)
A session store encapsulates storing the secret — a file here, a database or anything else in a custom store — and is the only one with access to the stored record. So the store validates the stored record against its own format, and refuses a record that is not of it as a whole: nothing is answered, nothing is guessed, nothing is composed from other keys of the file. Whether the credential itself is valid is the target system's or the identity provider's to say; whether a written binding matches the destination stays auth-broker's.
The format of the file stores (AbapSessionStore, EnvFileSessionStore, XsuaaSessionStore) is what their writer always produces:
- beside a token or cookies, both binding keys —
SAP_ISSUED_FORandSAP_ISSUED_BY(XSUAA_ISSUED_FOR/XSUAA_ISSUED_BY), present even when empty; an empty key means "no binding"; - a token or cookies, never both;
SAP_EXPIRES_AT/XSUAA_EXPIRES_AT, when present, decimal epoch milliseconds (a safe integer);SAP_SESSION_COOKIES_B64, when present, the writer's base64.
A file with no token and no cookies holds no credential to bind: a file holding only the destination's means (an --env file) is no session, not an error, and a refresh token alone is answered as it is. An empty key reads as absent. Only plain code runs over the record.
A record not of the format is refused with a StorageError — code STORAGE_ERROR, reason: 'record-format', operation: 'read' — in fixed words naming the store and the destination: <Store>: the stored session for "<destination>" is not in this store's format; regenerate it with mcp-auth. No value of the record — no token, no URL — and no path is in it.
On a write over such a record: a write of a new credential (a token or cookies) replaces the whole secret and proceeds — nothing of the old record is carried, its refresh token included — and the result is a valid record; any other write (only expiresAt, a refresh token, a binding, clearing a credential) is refused with the same error and leaves the file byte for byte. deleteSession removes the record's keys as always.
A session file written without binding keys — by auth-stores 2.x or 3.0.0, auth-broker 3.x, or by hand — is therefore refused. Regenerate it with mcp-auth, which always writes the binding keys:
mcp-auth --service-key <key.json> --output <file> --type abap # or --type xsuaa
mcp-auth --destination <name> --type abap # or --type xsuaa
3.1.0–4.x stores composed a binding for such a file from its means keys; 5.0.0 neither composes one nor answers the credential without it (see the CHANGELOG's migration note).
Usage
Composing the two stores
The consumer — the broker's caller — decides where means and secrets live; no store picks a directory by itself.
import {
AbapServiceKeyStore,
AbapSessionStore,
EnvDestinationStore,
} from '@mcp-abap-adt/auth-stores';
// One directory: means and secret in the same <destination>.env.
// Each store touches only its own keys, so neither loses the other's.
const sessions = '/home/me/.config/mcp-abap-adt/sessions';
const keyStore = new EnvDestinationStore(sessions, {
// a SAP service key supplies the client and URL; the .env file the grant
fallback: new AbapServiceKeyStore('/home/me/.config/mcp-abap-adt/service-keys'),
});
const sessionStore = new AbapSessionStore(sessions);
// Or two directories: the secret apart from the means.
const meansStore = new EnvDestinationStore('/etc/mcp-abap-adt/destinations');
const secretStore = new AbapSessionStore('/var/lib/mcp-abap-adt/sessions');
Writing means is EnvDestinationStore's own method — IServiceKeyStore is read-only. A given field is set, null removes it, a field not given stays:
await keyStore.setDestination('TRIAL', {
authType: 'jwt',
grantType: 'authorization_code',
});
await keyStore.setDestination('DEV', {
serviceUrl: 'https://dev.example.com',
authType: 'basic',
username: 'DEVELOPER',
password: '...',
sapClient: '100',
});
await keyStore.setDestination('DEV_SNC', {
serviceUrl: 'https://dev.example.com',
authType: 'snc',
sncPartnerName: 'p:CN=DEV, O=ORG, C=DE',
});
// A public OIDC client: the secret is stated as ''
await keyStore.setDestination('IDP', {
serviceUrl: 'https://h.abap.example',
authType: 'jwt',
grantType: 'device_code',
uaaUrl: 'https://idp.example/realms/r',
uaaClientId: 'public-client',
uaaClientSecret: '',
oidcIssuerUrl: 'https://idp.example/realms/r',
oidcScopes: ['openid', 'offline_access'],
});
// The secret, after a login, goes to the session store
await sessionStore.saveSession('TRIAL', {
authorizationToken: '<jwt>',
expiresAt: Date.now() + 3_600_000,
refreshToken: '<refresh token>',
});
EnvDestinationStore
new EnvDestinationStore(directory, { fallback?, variables?, log? }) — directory has no default; a destination is <directory>/<destination>.env.
EnvDestinationStore.forFile(path, { fallback?, variables?, log? }) — one given file, whatever its name (.env.dev, conn.cfg): every destination name resolves to that file and is not used to find it, as with EnvFileSessionStore. The fallback is still asked by the destination name.
-
Reads every means field of
IConnectionConfig(getConnectionConfig) and the client (getAuthorizationConfig: answered wheneveruaaClientIdis stated and not empty —''is no id, so no client;uaaUrloruaaClientSecretnot stated is answered as'', so an OIDC client — an issuer, no UAA URL — or a public client — no secret — is a client too. Whether a grant needs those fields is the broker's to decide, not the store's).getServiceKeyanswers both together. -
Answers means only: no token, cookies, expiry or refresh token, from the file or the fallback.
-
Infers nothing: a file without
SAP_AUTH_TYPEstates no type; a type or grant it does not know is answered as stored, for the consumer to name. With a fallback, the missing type comes from the fallback: a 1.x/2.xbasicfile withoutSAP_AUTH_TYPEbeside a SAP service key reads asauthType: 'jwt'(the key's) with the file'susername/password— stateSAP_AUTH_TYPE=basicin such a file, orSAP_AUTH_TYPE=jwtandSAP_GRANT_TYPEfor a token destination. -
Fallback: another
IServiceKeyStorefills, field by field, what the file leaves out. A field the file states —''included — wins. -
Writes (
setDestination,deleteDestination): only its means keys. A secret field, or any field that is not means, is refused with aRefusedFieldsError; a malformed value (an unknownauthTypeorgrantType, a scope with whitespace, a non-booleansamlIdpInitiated) with anInvalidConfigErrornaming the field. -
Destination names are file names: one containing
/,\or..is refused (InvalidConfigError) by every store that builds a path from it —EnvDestinationStore,AbapSessionStore,XsuaaSessionStore— for reads and writes.''is the file named.env. (forFileandEnvFileSessionStoredo not use the name.) -
Key names:
variables: ABAP_DESTINATION_VARS(the default) orXSUAA_DESTINATION_VARS. -
A client certificate (3.3.0) is three means of this store's own —
CertificateField:uaaClientCertPath,uaaClientKeyPath,uaaCertUrl. Two paths and a URL, never PEM: the files are the user's own and are read only bygetClientCertificate.Field ABAP_DESTINATION_VARSXSUAA_DESTINATION_VARSuaaClientCertPath— the PEM certificate (or chain) fileSAP_UAA_CLIENT_CERT_PATHXSUAA_UAA_CLIENT_CERT_PATHuaaClientKeyPath— the PEM private key fileSAP_UAA_CLIENT_KEY_PATHXSUAA_UAA_CLIENT_KEY_PATHuaaCertUrl— the mTLS host of the authorization server (XSUAAcerturl)SAP_UAA_CERT_URLXSUAA_UAA_CERT_URLWhich variables are set decides; no file is read to decide. A variable written as
''is not set.- None of the three → as before;
getClientCertificateanswersnull(or the fallback's, below). - All three and no client secret variable → a certificate client:
getAuthorizationConfiganswersnull, andgetClientCertificatereads the two files and answers{ uaaUrl, clientId, certificate, key, certUrl }with the file's*_UAA_URLand*_UAA_CLIENT_ID— never the fallback's. Without those two it refuses asincomplete. - Some but not all three, or any of them with the client secret variable (
''included) → both methods throw aClientCertificateErrornaming the variables (incomplete/mixed), and no file is read. A file that cannot be read →unreadable, naming the variable and the error code, never the path or its content. - The fallback is asked for a certificate only when the file states no client at all — no client secret variable, no client id, none of the three. A file stating a client (a secret, or an id: the client is its id) answers
nullwith the fallback unasked, sogetAuthorizationConfigandgetClientCertificatenever answer two different clients. So anEnvDestinationStorewith anXsuaaServiceKeyStorefallback answers an x509 key's certificate when its file states no client — only the grant and the service URL, say. - Writing:
setDestinationwrites the three as it writes the others. Writing a certificate client removes the client secret variable; writing a client secret removes the three — a switch leaves no credential of the other kind. A write carrying both is refused (mixed). - A custom
variablesmap without the three keys (they are optional inDestinationVariables, so a 3.2.0 map still compiles) supports no certificate destination:getClientCertificateanswersnull(or the fallback's), a write carrying a certificate field is refused (InvalidConfigError), and a secret write cannot remove certificate lines it has no names for — an unmapped certificate line in the file stays as it is.
- None of the three → as before;
Env file format
One <destination>.env may hold both roles. The means keys (read and written by EnvDestinationStore, ABAP_DESTINATION_VARS):
SAP_URL=https://your-sap-system.com
SAP_AUTH_TYPE=jwt # basic | jwt | saml | snc
SAP_GRANT_TYPE=authorization_code # authorization_code | client_credentials | passcode |
# oidc_authorization_code | device_code | password |
# token_exchange | saml2_pure | saml2_bearer | none
SAP_CLIENT=100
SAP_LANGUAGE=EN
# basic
SAP_USERNAME=DEVELOPER
SAP_PASSWORD='...'
# snc
SAP_SNC_PARTNERNAME='p:CN=SID, O=ORG, C=DE'
SAP_SNC_QOP=9
SAP_SNC_LIB=/usr/sap/sapcrypto/libsapcrypto.so
SAP_SNC_MYNAME=p:CN=ME
# the client: its id is what makes one; a missing URL or secret is answered
# as '' (an OIDC client has an issuer instead; a public client has no secret)
SAP_UAA_URL=https://uaa.example.com
SAP_UAA_CLIENT_ID=client-id
SAP_UAA_CLIENT_SECRET=client-secret
# or a client certificate (3.3.0) instead of the secret — paths and a URL,
# never PEM; SAP_UAA_CLIENT_SECRET must then be absent
# SAP_UAA_CLIENT_CERT_PATH=/home/me/.config/mcp-abap-adt/certs/client.pem
# SAP_UAA_CLIENT_KEY_PATH=/home/me/.config/mcp-abap-adt/certs/client.key
# SAP_UAA_CERT_URL=https://sub.authentication.cert.eu10.hana.ondemand.com
# OIDC
SAP_OIDC_ISSUER_URL=https://idp.example/realms/r
SAP_OIDC_AUTHORIZATION_ENDPOINT=...
SAP_OIDC_TOKEN_ENDPOINT=...
SAP_OIDC_DEVICE_AUTHORIZATION_ENDPOINT=...
SAP_OIDC_SCOPES='openid offline_access' # space-separated
SAP_OIDC_SUBJECT_TOKEN=... # token_exchange
SAP_OIDC_SUBJECT_TOKEN_TYPE=...
SAP_OIDC_AUDIENCE=...
SAP_OIDC_ACTOR_TOKEN=...
SAP_OIDC_ACTOR_TOKEN_TYPE=...
# SAML
SAP_SAML_IDP_SSO_URL=https://idp.example/sso
SAP_SAML_IDP_ENTITY_ID=https://idp.example
SAP_SAML_IDP_CERTIFICATES_B64=<base64 of cert 1>,<base64 of cert 2> # each certificate's text, base64
SAP_SAML_SP_ENTITY_ID=https://h.abap.example
SAP_SAML_ACS_URL=https://h.abap.example/sap/saml2/sp/acs
SAP_SAML_RELAY_STATE=...
SAP_SAML_IDP_INITIATED=true # true | false
SAP_SAML_CLOCK_SKEW_MS=30000
SAP_SAML_TOKEN_URL=https://uaa.example/oauth/token/alias/... # saml2_bearer
The secret keys (read and written by AbapSessionStore and EnvFileSessionStore, ABAP_SESSION_VARS):
SAP_JWT_TOKEN=<access token> # or:
SAP_SESSION_COOKIES_B64=<base64 of the Cookie header value>
SAP_EXPIRES_AT=1790000000000 # epoch ms
SAP_REFRESH_TOKEN=<refresh token>
SAP_ISSUED_FOR=<URI of the resource> # 3.1.0: written with every credential,
SAP_ISSUED_BY=<URI of issuer and client> # empty when there is none
XSUAA_DESTINATION_VARS and XSUAA_SESSION_VARS are the same with XSUAA_, the URL under XSUAA_MCP_URL. Values with spaces or special characters are written in the first quote character they do not contain — ', then `, then " — so dotenv reads them back unchanged. Two values cannot be written, and are refused with a StorageError naming the key: one with a line break (certificates are base64-encoded for that reason), and one containing ', ` and " together (no dotenv quoting reads it back).
One key per line. The stores write one KEY=value per line. In a hand-written file they find a key where dotenv does — KEY=value, export KEY=value, KEY: value, a quoted value spanning lines, a duplicate (the last wins in dotenv; a write keeps one) — and change nothing else, CRLF line ends included. Every rewrite is read back with dotenv before it replaces the file; if any key would not read as intended, the write is refused with a StorageError naming the file and the key, and the file is left alone.
2.x session files
A file written by auth-stores 2.x (or auth-broker 3.x) holds means and secret together. 3.0.0 reads it where it is, split by key — nothing is moved:
2.x key (ABAP; the XSUAA stores' XSUAA_* keys alike) |
Role in 3.0.0 | Read by |
|---|---|---|
SAP_JWT_TOKEN, SAP_SESSION_COOKIES_B64, SAP_REFRESH_TOKEN (and the new SAP_EXPIRES_AT, and 3.1.0's SAP_ISSUED_FOR / SAP_ISSUED_BY) |
secret | the session store |
SAP_URL, SAP_AUTH_TYPE, SAP_USERNAME, SAP_PASSWORD, SAP_SNC_*, SAP_UAA_URL, SAP_UAA_CLIENT_ID, SAP_UAA_CLIENT_SECRET, SAP_CLIENT, SAP_LANGUAGE |
means | EnvDestinationStore, pointed at the same directory |
A session write rewrites only the secret keys, so a 2.x file stays a complete 2.x file. Its credential has no binding keys beside it, so a session store refuses the record (StorageError, record-format; see The store validates its record): regenerate the session with mcp-auth, or write a new credential, which replaces the secret keys and leaves the means lines as they are. A 2.x basic or snc file is a complete destination as it is. A 2.x jwt or saml file states no grant — 2.x never wrote one — so add SAP_GRANT_TYPE (by hand, or through setDestination). A 3.x public-client file (mcp-sso stripped its __public__ secret line) has no SAP_UAA_CLIENT_SECRET; from 3.2.0 its client is answered with the secret '', a public client, as it is. A file without SAP_AUTH_TYPE read with a service key fallback takes the key's jwt — see Infers nothing above.
BTP / XSUAA stores
import {
XsuaaServiceKeyStore,
XsuaaSessionStore,
SafeXsuaaSessionStore,
EnvDestinationStore,
XSUAA_DESTINATION_VARS,
} from '@mcp-abap-adt/auth-stores';
const serviceKeyStore = new XsuaaServiceKeyStore('/path/to/service-keys', {
grantType: 'client_credentials', // what the key cannot state (3.1.0)
});
// the MCP URL is means: state it in a destination store
const keyStore = new EnvDestinationStore('/path/to/sessions', {
variables: XSUAA_DESTINATION_VARS,
fallback: serviceKeyStore,
});
const sessionStore = new XsuaaSessionStore('/path/to/sessions', logger);
const safeSessionStore = new SafeXsuaaSessionStore(logger);
EnvFileSessionStore (single file)
EnvFileSessionStore holds the secret in one given file — the --env /path/to/.env.dev case — and EnvDestinationStore.forFile reads and writes the means in the same file. Neither uses the destination name to find the file: any name resolves to it.
import { EnvDestinationStore, EnvFileSessionStore } from '@mcp-abap-adt/auth-stores';
const envPath = '/path/to/.env.dev';
const sessionStore = new EnvFileSessionStore(envPath, logger);
const keyStore = EnvDestinationStore.forFile(envPath, { log: logger });
const means = await keyStore.getConnectionConfig('default');
const secret = await sessionStore.loadSession('default'); // the file's secret keys
Besides the contract it offers getToken, setToken, getRefreshToken, setRefreshToken. 2.x's getAuthType() (the type is means), save() and clear() (the in-memory layer is gone: writes reach the file at once) were removed.
Directory Configuration
Every directory is a constructor parameter; a file store reads and writes only <directory>/<destination>.env (or .json) and searches nowhere else.
Service Key Format
ABAP Service Key (with nested uaa object):
{
"uaa": {
"url": "https://...authentication...hana.ondemand.com",
"clientid": "...",
"clientsecret": "..."
},
"abap": {
"url": "https://...abap...hana.ondemand.com",
"client": "001"
}
}
XSUAA Service Key (direct format):
{
"url": "https://...authentication...hana.ondemand.com",
"clientid": "...",
"clientsecret": "...",
"apiurl": "https://...api...hana.ondemand.com"
}
XSUAA x509 Service Key (3.3.0, read by XsuaaServiceKeyStore only; direct or in a credentials wrapper; with or without a clientsecret):
{
"url": "https://...authentication...hana.ondemand.com",
"clientid": "...",
"certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
"key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n",
"certurl": "https://...authentication.cert...hana.ondemand.com",
"credential-type": "x509"
}
File Handlers
This package provides utility classes for safe file operations:
Error Handling
All service key stores throw typed errors for better error handling:
import {
BtpServiceKeyStore,
FileNotFoundError,
ParseError,
InvalidConfigError
} from '@mcp-abap-adt/auth-stores';
import { STORE_ERROR_CODES } from '@mcp-abap-adt/interfaces-auth';
const serviceKeyStore = new BtpServiceKeyStore('/path/to/keys');
try {
const authConfig = await serviceKeyStore.getAuthorizationConfig('TRIAL');
console.log('Auth config loaded:', authConfig);
} catch (error: any) {
if (error.code === STORE_ERROR_CODES.FILE_NOT_FOUND) {
// File not found - returns null instead of throwing
console.error('Service key file not found:', error.filePath);
} else if (error.code === STORE_ERROR_CODES.PARSE_ERROR) {
// JSON parsing failed or invalid format
console.error('Failed to parse service key:', error.filePath);
console.error('Cause:', error.cause);
} else if (error.code === STORE_ERROR_CODES.INVALID_CONFIG) {
// Required UAA fields missing - returns null instead of throwing
console.error('Invalid config:', error.missingFields);
} else if (error.code === STORE_ERROR_CODES.STORAGE_ERROR) {
// File write/permission error
console.error('Storage operation failed:', error.operation);
console.error('Cause:', error.cause);
} else {
// Generic error
console.error('Unexpected error:', error.message);
}
}
Error Types:
FileNotFoundError- Service key file not found (includesfilePath)ParseError- JSON parsing failed or invalid format (includesfilePathandcause). From 3.3.0 the service key stores (AbapServiceKeyStore,XsuaaServiceKeyStore) raise it in fixed words naming the destination, with nofilePathand nocause: an underlying message may quote the file (Node'sJSON.parsedoes), and a key file holds a client secret or a private keyInvalidConfigError- Required configuration fields missing (includesmissingFieldsarray)StorageError- File read, write or permission error (includesoperationandcause); a session file that exists but cannot be read raises it — only a missing file isnull. From 5.0.0 also a session record not of its store's format:reason: 'record-format', fixed words naming the store and the destination, never a value of the record (see The store validates its record);reasonis absent for an I/O failureRefusedFieldsError(codeINVALID_CONFIG) - A write carried fields the store does not hold: means given to a session store, a secret given toEnvDestinationStore(includesfields; the message names fields, never values)ClientCertificateError(codeINVALID_CONFIG, 3.3.0) - A destination's client certificate cannot be answered (includesreason:incomplete,mixedorunreadable, andvariables: the env keys —EnvDestinationStore, all three reasons — or the service key's fields —XsuaaServiceKeyStore,incompleteonly). The message is fixed words naming the destination and those names — never a value, a path, a file's content or a certificate.
Note: Most errors result in null return values rather than exceptions. Only fatal errors (like JSON parsing failures) throw exceptions — and a client certificate that is incomplete, unreadable or (in an .env file) mixed with a secret, which throws a ClientCertificateError rather than answer a client the destination does not state.
File Handlers
Utility classes for working with files:
JsonFileHandler
import { JsonFileHandler } from '@mcp-abap-adt/auth-stores';
// Load JSON file
const data = await JsonFileHandler.load('TRIAL.json', '/path/to/directory');
// Save JSON file (atomic write)
await JsonFileHandler.save('/path/to/file.json', { key: 'value' });
EnvFileHandler
import { EnvFileHandler } from '@mcp-abap-adt/auth-stores';
// Load .env file
const vars = await EnvFileHandler.load('TRIAL.env', '/path/to/directory');
// Save .env file (atomic write, preserves existing variables)
await EnvFileHandler.save('/path/to/file.env', {
KEY1: 'value1',
KEY2: 'value2'
}, true); // preserveExisting = true
Utilities
Constants
import {
ABAP_DESTINATION_VARS, // means keys of EnvDestinationStore (SAP_*)
XSUAA_DESTINATION_VARS, // the same, XSUAA_*
ABAP_SESSION_VARS, // secret and binding keys of AbapSessionStore / EnvFileSessionStore
XSUAA_SESSION_VARS, // secret and binding keys of XsuaaSessionStore
// the 2.x tables, unchanged:
ABAP_AUTHORIZATION_VARS,
ABAP_CONNECTION_VARS,
BTP_AUTHORIZATION_VARS,
BTP_CONNECTION_VARS,
XSUAA_AUTHORIZATION_VARS,
XSUAA_CONNECTION_VARS,
} from '@mcp-abap-adt/auth-stores';
Service Key Loaders
import { loadServiceKey, loadXSUAAServiceKey } from '@mcp-abap-adt/auth-stores';
// Load ABAP service key (auto-detects format)
const abapKey = await loadServiceKey('TRIAL', '/path/to/service-keys');
// Load XSUAA service key
const xsuaaKey = await loadXSUAAServiceKey('mcp', '/path/to/service-keys');
The loaders do not read client certificates: for an x509 key without a secret, loadXSUAAServiceKey answers null and loadServiceKey refuses it as no supported format, and for one with a secret both answer the secret client — exactly as in 3.2.0 — their result has no place for a certificate, and a missing clientsecret would read as a public client. Read an x509 key with XsuaaServiceKeyStore.getClientCertificate (see Client certificates).
Debug Logging
Stores support optional logging through the ILogger interface. To enable detailed logging:
Using Logger in Code
import { AbapServiceKeyStore } from '@mcp-abap-adt/auth-stores';
import type { ILogger } from '@mcp-abap-adt/interfaces-utils';
// Create logger (or use your own implementation)
const logger: ILogger = {
debug: (msg) => console.debug(msg),
info: (msg) => console.info(msg),
warn: (msg) => console.warn(msg),
error: (msg) => console.error(msg),
};
// Pass logger to store constructor
const store = new AbapServiceKeyStore('/path/to/service-keys', logger);
const sessionStore = new AbapSessionStore('/path/to/sessions', logger);
Using Test Logger in Tests
For tests, use the createTestLogger helper which respects environment variables:
import { createTestLogger } from './__tests__/helpers/testLogger';
// Logger will output only if DEBUG_AUTH_STORES=true is set
const logger = createTestLogger('MY-TEST');
const store = new AbapServiceKeyStore('/path/to/service-keys', logger);
Environment Variables
To enable logging in tests or when using createTestLogger:
# Enable logging for auth stores (short name)
DEBUG_STORES=true npm test
# Or use long name (backward compatibility)
DEBUG_AUTH_STORES=true npm test
# Or enable via general DEBUG variable
DEBUG=true npm test
# Or include in DEBUG list
DEBUG=stores npm test
# Or
DEBUG=auth-stores npm test
# Set log level (debug, info, warn, error)
LOG_LEVEL=debug npm test
Note: Logging requires explicit enable via environment variables. Both DEBUG_STORES (short) and DEBUG_AUTH_STORES (long) are supported for backward compatibility.
Logging shows:
- File operations: Which files are read/written, file sizes, file paths
- Parsing operations: Structure of parsed data, validation results, keys found
- Storage operations: What data is saved/loaded, token lengths, refresh token presence, URLs
- Token formatting: Tokens are logged as
<redacted, N chars>— never a character of the token - Errors: Detailed error information with context
Logging Features:
- Token Formatting: Tokens are logged as
<redacted, N chars> - Structured Logging: Uses
DefaultLoggerfrom@mcp-abap-adt/loggerfor proper formatting with icons and level prefixes - Log Levels: Controlled via
LOG_LEVELorAUTH_LOG_LEVELenvironment variable (error, warn, info, debug)
Example output with DEBUG_STORES=true LOG_LEVEL=debug:
[INFO] ℹ️ [TEST-STORE] Reading service key file: /path/to/TRIAL.json
[DEBUG] 🐛 [TEST-STORE] File read successfully, size: 121 bytes, keys: uaa
[DEBUG] 🐛 [TEST-STORE] Parsed service key structure: hasUaa(true), uaaKeys(url, clientid, clientsecret)
[INFO] ℹ️ [TEST-STORE] Authorization config loaded from /path/to/TRIAL.json: uaaUrl(https://...authentication...), clientId(test-client...)
[DEBUG] 🐛 [TEST-STORE] Session loaded for TRIAL: token(<redacted, 2263 chars>), expiresAt, refreshToken(<redacted, 34 chars>)
Note: Logging only works when a logger is explicitly provided. Stores will not output anything to console if no logger is passed.
Testing
The package includes both unit tests (with mocked file system) and integration tests (with real files).
Unit Tests
Unit tests use Jest with mocked file system operations:
npm test
Checks
npm run build # biome errors, then tsc -p tsconfig.build.json (no tests in dist)
npm run test:check # tsc --noEmit over src and its tests
npm run lint:check # biome, read-only; a warning fails it
npm test
CI runs all four. The compiler is strict plus noImplicitReturns,
noFallthroughCasesInSwitch, noImplicitOverride, noUncheckedIndexedAccess
and exactOptionalPropertyTypes, for the tests too.
Integration Tests
Integration tests work with real files from tests/test-config.yaml:
- Copy
tests/test-config.yaml.templatetotests/test-config.yaml - Fill in real paths and destinations
- Run tests - integration tests will use real files if configured
auth_broker:
paths:
service_keys_dir: ~/.config/mcp-abap-adt/service-keys/
sessions_dir: ~/.config/mcp-abap-adt/sessions/
abap:
destination: "TRIAL"
xsuaa:
btp_destination: "mcp"
mcp_url: "https://..."
Integration tests return at once, with a warning, if test-config.yaml is not configured or contains placeholder values (the template's). The session write cases write a destination of their own and remove it — they never touch the configured destination's session.
Architecture
File Operations
- Service key stores read JSON files through
JsonFileHandler .envstores read with dotenv and write by rewriting only their own keys: every other line — keys of the other role, comments, blank lines — stays byte for byte, so one file may be shared byEnvDestinationStoreand a session store- Every write is atomic: a temporary file of the writer's own (
<file>.<pid>.<random>.tmp, created exclusively, removed on failure), then a rename - A new file is created
0600; an existing one is narrowed to its owner's bits on every rewrite (mode & 0o600: a 2.x0644file becomes0600), never widened - Concurrent writers lose nothing. Writes for one destination run one after another within a store instance. Across instances and processes, every writer of a
.envfile — a session store's secret,EnvDestinationStore's means,deleteSession,deleteDestination— holds an advisory lock,<file>.lock(created exclusively), from its read to its rename. A live lock is waited for, retrying, up to 10 s, then the write fails with aStorageErrornaming the file; a lock older than 30 s is a crashed holder's and is taken over. Readers take no lock: a rename is atomic. A writer that does not take the lock (2.x, an editor) is not excluded — its own<file>.tmpis never touched, but its rename may replace a concurrent 3.0 write - A record not of the store's format — a malformed
SAP_EXPIRES_ATamong them — is refused by reads and by a partial write (StorageError,record-format), and replaced by a write of a new credential - A file that exists but cannot be read raises a
StorageError; only a missing file is "nothing stored"
Store Implementation
- Key stores implement
IServiceKeyStoreand answer means only; session stores implementISessionStoreand hold the session secret only (both contracts from@mcp-abap-adt/interfaces-auth-broker) - Every directory is a constructor parameter; no store searches other locations
- File-based session stores create their directory in the constructor if it is missing
- In-memory stores (
Safe*SessionStore) do not persist anything to disk - Nothing a store holds reaches a log line or an error message: tokens are logged as
<redacted, N chars>, means by field name only,issuedFor/issuedByby name only
Dependencies
@mcp-abap-adt/interfaces-auth-broker(^1.3.0) - the store contracts (IServiceKeyStore,ISessionStore,IConfig,IConnectionConfig,DestinationGrant)@mcp-abap-adt/interfaces-auth-sap(^3.3.0) -IAuthorizationConfig@mcp-abap-adt/interfaces-auth(^7.5.0) -STORE_ERROR_CODES@mcp-abap-adt/interfaces-utils(^1.1.1) -ILoggerdotenv- Environment variable parsing
License
GNU Lesser General Public License v3.0 only (LGPL-3.0-only).
Earlier published versions were MIT and stay MIT — a licence change is not
retroactive.
Copyright © 2025–2026 Oleksii Kyslytsia
This library is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, version 3.
It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
Both texts ship with the package and both are needed: LICENSE is the
LGPL, COPYING is the GPL it is written on top of, since the LGPL is a
set of additional permissions over the GPL and cannot be read alone.
What this means if you depend on this package. Linking it into your own program — importing it, as every consumer of an npm package does — does not put your program under the LGPL. What the licence asks is that changes to this library stay free, and that your users can replace it with their own build.
