SAP <-> OTDS Authentication / SSO¶
This page describes how SAP S/4HANA and OpenText Directory Services (OTDS) can trust
each other, and what pyxecm provides for it:
| Direction | Use case | Mechanism | pyxecm |
|---|---|---|---|
| 1. OTDS trusts SAP | A user logged on to SAP opens an OTDS-protected application without logging on again | SAP logon ticket (MYSAPSSO2), validated by the OTDS SAPSSOEXT handler |
OTDS.add_auth_handler_sap() |
| 2. SAP trusts pyxecm | pyxecm calls SAP OData APIs as the individual user ("principal propagation") | pyxecm signs a SAML 2.0 assertion, SAP exchanges it for an OAuth 2.0 access token (RFC 7522) | SAP.get_oauth_access_token_for_user(), BearerAuth |
| Fallback | pyxecm calls SAP with one shared technical user | HTTP basic authentication | SAP(username=..., password=...) |
Related docs: SAP module reference, SAP APIs that must be activated.
Terminology¶
| Term | Meaning |
|---|---|
| Principal propagation | A service calls a backend under the identity of the end user (the user's own SAP authorizations and change documents), not as a technical user. |
MYSAPSSO2 |
SAP's logon ticket (cookie), issued by SAP after a user logged on. |
| SAML 2.0 assertion | A signed XML document in which an identity provider (IdP) states who a user is. |
| SAML 2.0 Bearer Assertion grant | OAuth 2.0 flow (RFC 7522): a client posts a SAML assertion to the token endpoint and gets an access token for the user named in it. |
Transaction SAML2 |
SAP's SAML 2.0 configuration: the SAP system's own local provider and the trusted (identity) providers, incl. how their users map to SAP users. |
Transaction SOAUTH2 |
SAP's OAuth 2.0 server configuration: OAuth clients, their grant types, trusted IdPs and scopes. |
| Local provider | The SAML 2.0 identity of the SAP system itself, configured in SAP S/4HANA transaction SAML2 by enabling SAML 2.0 (step 2.1). Its local provider name (e.g. S4HCLNT100) is the audience of pyxecm's assertions and must be set as saml_audience (SAP__SAML_AUDIENCE) in pyxecm. There is one local provider per SAP client, and it is required even if pyxecm is the only identity provider SAP trusts. |
| Trusted provider | An entry in the Trusted Providers list of SAP S/4HANA transaction SAML2 identifying an external identity provider SAP accepts assertions from - pyxecm registers itself here (step 2.2), using saml_issuer as its entityID/provider name. |
| OTDS ticket / OTDS access token | OTDS's session token (*OTDSSSO*...) / an RS256-signed JWT from OTDS's token endpoint (/otdsws/oauth2/token). |
| OAuth 2.0 client | The application that requests access tokens from SAP's OAuth 2.0 server - here pyxecm. It is registered in SAP S/4HANA transaction SOAUTH2 and authenticates with a client ID and a client secret (step 2.5). Not to be confused with an SAP client (sap-client, e.g. 100), the separate data area of an SAP system that all SAP-side settings belong to. |
| System user | An SAP user of user type System (set in SAP S/4HANA transaction SU01) for machine-to-machine communication: it cannot log on interactively (no SAP GUI / Fiori logon) and its password does not expire. In SAP, every OAuth 2.0 client is backed by such a user: its user name is the OAuth client ID (SAP__OAUTH_CLIENT_ID, e.g. PYXECM_OAUTH), its password is the OAuth client secret (SAP__OAUTH_CLIENT_SECRET). It is not an end user and needs no business authorizations. |
| OAuth 2.0 scope | A named permission an access token carries - in SAP one scope per OData service, e.g. ZAPI_MAINTENANCEORDER_0002, created by enabling OAuth for the service (step 2.4). A token can only call the OData services whose scopes it was granted. SAP grants a scope only if it is assigned to the OAuth client (SOAUTH2) and the end user is authorized for it (authorization object S_SCOPE). pyxecm requests the scopes listed in SAP__OAUTH_SCOPE. Scopes limit what pyxecm can do with a token, on top of the end user's own authorizations. |
Transaction PFCG |
SAP's role maintenance: authorizations (e.g. S_SERVICE for OData services) are bundled in roles, and the roles are assigned to users. This is where the SAP administrator gives users the authorizations they need (step 2.6). |
Both must be configured in SAP S/4HANA transaction SAML2 - they are two independent, separately-enabled things:
the SAP system's own identity (local provider, step 2.1) and pyxecm's identity as
an external IdP SAP trusts (trusted provider, step 2.2). Having one configured does not
imply the other is - e.g. an OAuth client that already references pyxecm as its Trusted
OAuth 2.0 IdP can still fail everything if the local provider itself was never enabled
(/sap/saml2/sp/metadata then returns a SAML 2.0 error page, see
step 2.1).
Checklist for the SAP administrator¶
Everything that has to be done in S/4HANA, in this order. All of it is per SAP client
(sap-client). Each item links to the details.
A. OData services¶
- Activate every service listed in SAP APIs that must be activated -
V2 in SAP S/4HANA transaction
/IWFND/MAINT_SERVICE, V4 in SAP S/4HANA transaction/IWFND/V4_ADMIN(see General activation steps). - Check that the ICF nodes are active in SAP S/4HANA transaction
SICF(/sap/opu/odata/sap/<service>for V2,/sap/opu/odata4for V4). - Authorizations (SAP S/4HANA transaction
PFCG, role maintenance):S_SERVICEfor the services plus the business authorizations behind them. With principal propagation, every end user needs them in their own roles; with the technical user, only that user. - Smoke test:
GET .../$metadataof each service in SAP S/4HANA transaction/IWFND/GW_CLIENTreturns HTTP 200. - Verify the actual status per service against your system with
SAP.check_odata_apis()(see Verifying activation) - it reports, per service, whether it is active or which step above is still missing (not activated, ICF node inactive, missing system alias, etc. - see troubleshooting).debug/debug_sap.pydoes the same as part of its smoke test.
B. Principal propagation (SAML 2.0 + OAuth 2.0)¶
Only needed if pyxecm (e.g. the Content Aviator SAP agents) calls SAP as the individual user. The step numbers refer to the Setup of Direction 2 below - step 1 is done by the pyxecm team, the steps 2.1 to 2.6 by the SAP administrator (2. Configure SAP).
- Get pyxecm's signing certificate
saml-idp.crt(or the metadata XML) from the pyxecm team - see step 1: identity provider key pair. -
SAML2: enable SAML 2.0 for the SAP client and note the local provider name (step 2.1). Check whether it is already enabled by requesting{base_url}/sap/saml2/sp/metadata?sap-client={client}- a SAML 2.0 error page means it is not enabled yet for that SAP client. -
SAML2: add pyxecm as trusted provider and make it available for OAuth 2.0 (step 2.2). -
SAML2: identity federation - the NameID format agreed with the pyxecm team and how it maps to the SAP user, e.g.emailAddress-> e-mail address orunspecified-> SAP user ID (step 2.3 lists all values). - Make sure the e-mail addresses are unique and end users cannot change their own
address (
SU3) - otherwise use an administrator-maintained alias (see Security considerations). -
/IWFND/MAINT_SERVICE: enable OAuth for every service of section A - this creates the scope IDs for the OAuth client (step 2.4). For OData V4 service groups, follow SAP's documentation of your release. -
SU01+SOAUTH2: system user (user type System) and OAuth 2.0 client with grant type SAML 2.0 Bearer Assertion, pyxecm as Trusted OAuth 2.0 IdP, and only the needed scopes (step 2.5). -
SOAUTH2: access token lifetime of at least 10 minutes - the Content Aviator plugin reuses a token for up to 5 minutes (see Operations). -
PFCG: the end users haveS_SERVICE,S_SCOPE(OAuth client + scope IDs) and the business authorizations (step 2.6). - Verify step by step with
SAP.check_oauth_token_endpoint()(endpoint activated, no client needed yet) and thenSAP.check_principal_propagation()(full round trip for a test user) - see Testing the setup. - Hand back to the pyxecm team - send the OAuth client secret (the password of the OAuth client's system user) only over a secure channel, e.g. a shared secret store, never by e-mail or chat - see Names that must match:
| SAP value | pyxecm setting (environment variable / payload field) |
|---|---|
Local provider name (SAML2) |
SAP__SAML_AUDIENCE / saml_audience |
NameID format (SAML2) |
SAP__SAML_NAMEID_FORMAT |
OAuth client ID (SOAUTH2) |
SAP__OAUTH_CLIENT_ID |
| Password of the OAuth client system user | SAP__OAUTH_CLIENT_SECRET |
| Scope IDs | SAP__OAUTH_SCOPE (space-separated, required) |
C. SSO from SAP into OTDS (optional)¶
-
STRUST: export SAP's ticket-signing certificate (system PSE) as.psefile and hand it over on a trusted channel - see Direction 1.
D. Technical user (only without principal propagation, or for RFC)¶
-
SU01: technical user withS_SERVICEand the business authorizations of all services - see Fallback: technical user. - For RFC calls:
S_RFC,SM59/ gateway ACLs - see RFC channel.
Direction 1: OTDS trusts SAP¶
The OTDS SAPSSOEXT authentication handler
(com.opentext.otds.as.drivers.sapssoext.SAPSSOEXTAuthHandler) validates a user's SAP
logon ticket (MYSAPSSO2) with SAP's public certificate. A user who is logged on to SAP
can then open OTDS-protected applications (Content Server, Customizer UI, ...) without
entering credentials again.
sequenceDiagram
participant User
participant SAP as SAP S/4HANA
participant App as OTDS-protected app
participant OTDS
User->>SAP: Logs on (gets MYSAPSSO2 cookie)
User->>App: Opens the app (browser sends MYSAPSSO2)
App->>OTDS: Redirects to the OTDS login
OTDS->>OTDS: SAPSSOEXT validates the ticket with SAP's certificate, maps the SAP user
OTDS-->>App: OTDS ticket/token for the mapped user
SAP side (SAP administrator):
- Export SAP's ticket-signing certificate (transaction
STRUST, system PSE) as a.psefile - no other SAP change is needed. - Make sure every SAP user can be mapped to an OTDS user, e.g. via an OTDS attribute such
as
oTExternalID1that contains the SAP user ID.
OTDS side (pyxecm):
from pyxecm import OTDS
otds = OTDS(protocol="https", hostname="otds.example.com", port=443, username="admin", password="...")
otds.authenticate()
otds.add_auth_handler_sap(
name="SAP",
description="SAP S/4HANA SSO Authentication Handler",
scope=None, # or a partition name to restrict the handler
certificate_file="/path/to/sap_sso2.pse",
certificate_password="...",
auth_principal_attributes=["oTExternalID1"],
)
Direction 2: Principal propagation into SAP¶
Why pyxecm is the identity provider¶
SAP's OData services accept bearer tokens only from SAP's own OAuth 2.0 server
(/sap/bc/sec/oauth2/token). That server issues a token for a user if it receives a
SAML 2.0 assertion for this user that is signed by an identity provider trusted in
transaction SAML2 (SAML 2.0 Bearer Assertion grant, RFC 7522). The obvious candidates
cannot provide that assertion:
- OTDS is a SAML 2.0 service provider only - it consumes assertions (its SAML
metadata contains only an
SPSSODescriptor), and its token exchange issues OTDS access tokens only (requested_token_type=...:saml2->Unsupported requested_token_type). - OTDS JWTs are rejected by SAP: OData returns
401for an OTDS token as bearer token, and SAP_BASIS 7.58 (S/4HANA 2023) has no inbound JWT Bearer grant (RFC 7523) - its JWT support is for SAP as OAuth client and for browser-based OpenID Connect logon.
So pyxecm acts as a minimal SAML 2.0 identity provider: it holds a private key whose
certificate the SAP administrator trusts in SAP S/4HANA transaction SAML2, and signs short-lived assertions for
users that the calling application has authenticated (via OTDS or a Content Server ticket).
OTDS stays the source of truth for who the user is; SAP only trusts pyxecm's signature.
sequenceDiagram
participant User
participant App as pyxecm (e.g. Aviator SAP agent)
participant OTDS
participant SAP as SAP S/4HANA
participant Deploy as Deployment
rect rgba(128, 128, 128, 0.1)
Note over App,Deploy: One-time setup (see Setup below)
Deploy->>Deploy: Create pyxecm's IdP key pair and SAML metadata XML
Deploy->>SAP: Read the local provider name from /sap/saml2/sp/metadata
SAP-->>Deploy: entityID, e.g. S4HCLNT100 = saml_audience
Deploy->>SAP: Upload the metadata XML as trusted provider (SAML2), create the OAuth client (SOAUTH2)
Deploy->>App: Provide the key pair (secret mount) and saml_issuer / saml_audience (payload)
end
rect rgba(128, 128, 128, 0.1)
Note over User,SAP: Every request
User->>App: Request with an OTDS session (ticket/token) or OTCS ticket
App->>OTDS: Validate the session (directly or via Content Server), get the user (e.g. e-mail address)
App->>App: get_saml_assertion(user) - sign with pyxecm's IdP key
App->>SAP: POST /sap/bc/sec/oauth2/token (saml2-bearer grant, OAuth client credentials)
SAP->>SAP: Verify signature (SAML2 trust), check audience/recipient/validity, map NameID to SAP user
SAP-->>App: SAP access token for the user
App->>SAP: OData call with Authorization: Bearer <SAP access token>
SAP-->>App: Response, processed with the user's own authorizations
end
The assertion¶
SAP.get_saml_assertion() produces an assertion that conforms to the SAML 2.0 Core
schema and RFC 7522, section 3:
| Element | Value | Why |
|---|---|---|
Assertion/@ID, @IssueInstant |
_ + random UUID, current UTC time |
Unique ID (logged by pyxecm, visible in SAP's trace) |
Issuer |
saml_issuer, e.g. pyxecm-idp |
Identifies the trusted provider in SAP S/4HANA transaction SAML2 |
ds:Signature |
Enveloped, RSA-SHA256, SHA-256 digest, exclusive canonicalization, optional certificate | Proves the issuer; directly after Issuer as the schema requires |
Subject/NameID |
The user, in the configured nameid_format |
Mapped to an SAP user by SAML2 (Identity Federation) |
SubjectConfirmation |
Method bearer, Recipient = SAP's token endpoint, NotOnOrAfter |
RFC 7522: only valid for this token endpoint |
Conditions |
NotBefore = now - 60 s, NotOnOrAfter = now + 120 s (max. 300 s) |
Short-lived; 60 s tolerance for clock differences |
Conditions/AudienceRestriction/Audience |
saml_audience = SAP's local provider name |
SAP rejects assertions meant for other systems |
AuthnStatement |
AuthnContextClassRef = ...:ac:classes:unspecified |
pyxecm does not authenticate the user itself - the calling application did |
get_oauth_access_token() base64url-encodes the assertion without padding (RFC 7522,
section 2.1) and authenticates the SOAUTH2 client with HTTP basic authentication.
Names that must match¶
Principal propagation only works if these values are identical on both sides. SAP compares them as plain strings - apart from being unique and stable they carry no meaning.
| # | pyxecm side | SAP side | Meaning | Chosen by |
|---|---|---|---|---|
| 1 | saml_issuer = entityID of the metadata XML |
Name of the trusted provider in SAP S/4HANA transaction SAML2 (taken from the metadata) |
pyxecm's SAML entity ID - who issues the assertion | You - e.g. pyxecm-idp or a URI; changing it later means registering the trust again |
| 2 | saml_audience |
Local provider name in SAP S/4HANA transaction SAML2 |
SAP's SAML entity ID - who the assertion is for | SAP administrator (see below) |
| 3 | Recipient = base_url + /sap/bc/sec/oauth2/token?sap-client=<client> |
URL of the token endpoint as SAP receives the request - incl. sap-client |
The assertion is only valid at this endpoint | Given by the URL - pass recipient if SAP is reached via a reverse proxy |
| 4 | Private key (saml_idp_private_key) |
Signing certificate of the trusted provider | Proves that the assertion comes from pyxecm | You - pyxecm checks at startup that certificate and key belong together |
| 5 | nameid_format and user_id |
Identity Federation of the trusted provider: allowed NameID format and its mapping | Which SAP user the assertion stands for | Agreed with the SAP administrator; the value must exist on the SAP user (e.g. e-mail in SU01) |
| 6 | client_id / client_secret |
OAuth client in SAP S/4HANA transaction SOAUTH2 = system user and its password |
Authenticates pyxecm as the calling application (not the end user) | SAP administrator |
| 7 | - | Trusted OAuth 2.0 IdP of the OAuth client = the trusted provider of row 1 | Lets this OAuth client accept pyxecm's assertions | SAP administrator |
| 8 | scope, e.g. API_MAINTENANCEORDER_0002 |
Scopes of the OAuth client - created by enabling OAuth for a service in SAP S/4HANA transaction /IWFND/MAINT_SERVICE |
Which OData services the token may call (service name + _ + version) |
SAP naming, assigned by the SAP administrator |
| 9 | client (sap-client of the token request) |
The SAP client in which rows 1-8 are configured | All of the above exists per SAP client | Given |
Rows 1 and 2 mirror each other: the Issuer names pyxecm, the Audience names SAP.
Row 8's scope ID is generated by SAP, not chosen freely - do not assume it is literally
<service>_<version>. Depending on the system, SAP may generate it in a customer
namespace with a Z or Y prefix (e.g. ZAPI_MAINTENANCEORDER_0002 instead of
API_MAINTENANCEORDER_0002). Always take the exact scope ID from the Scope Assignment
list of the OAuth client (step 2.4/2.5) or from SAP S/4HANA transaction /IWFND/MAINT_SERVICE, never guess it.
Where SAP's local provider name comes from: the SAP administrator chooses it once, when
enabling SAML 2.0 for an SAP client in SAP S/4HANA transaction SAML2. The wizard proposes a name derived
from system ID and client (e.g. S4HCLNT100); any other unique name, e.g. a URL, is
possible. It is the SAP system's SAML entity ID, so:
- It cannot practically be changed later - all trust relationships refer to it.
- Every SAP client has its own local provider (client 100 and client 200 are different SAML providers).
- It does not have to be passed on by hand: once SAML 2.0 is enabled, SAP publishes it as
entityIDin its metadata at{base_url}/sap/saml2/sp/metadata?sap-client={client}.
Setup¶
1. Create pyxecm's identity provider key pair (once per deployment)¶
openssl req -x509 -newkey rsa:3072 -sha256 -days 730 -nodes \
-keyout saml-idp.key -out saml-idp.crt -subj "/CN=pyxecm-idp"
chmod 600 saml-idp.key
saml-idp.keyis the secret: whoever has it can get an SAP access token for every user the SAP-side NameID mapping reaches. Store it in a secret store and mount it only into the pyxecm container. It must be an unencrypted RSA key with at least 2048 bits.saml-idp.crtis public - it goes to the SAP administrator.- Both files are PEM-encoded; pyxecm checks their content, not their names. A Kubernetes
TLS secret (
kubectl create secret tls ... --key saml-idp.key --cert saml-idp.crt) mounts them astls.keyandtls.crt- use these paths in the payload then. - Plan the renewal before the certificate expires (pyxecm logs a warning 30 days before).
SAML2can hold the old and the new certificate at the same time, so the switch has no downtime.
Some SAP releases accept a trusted provider only as a metadata file. This creates one from
the certificate (SingleSignOnService is required by the SAML metadata schema but never
called, as the bearer grant has no browser redirect):
from pathlib import Path
from lxml import etree
MD = "urn:oasis:names:tc:SAML:2.0:metadata"
DS = "http://www.w3.org/2000/09/xmldsig#"
certificate_pem = Path("saml-idp.crt").read_text()
certificate_body = "".join(line for line in certificate_pem.splitlines() if "CERTIFICATE" not in line)
entity = etree.Element(f"{{{MD}}}EntityDescriptor", nsmap={"md": MD, "ds": DS}, entityID="pyxecm-idp")
idp = etree.SubElement(
entity, f"{{{MD}}}IDPSSODescriptor", protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"
)
key_descriptor = etree.SubElement(idp, f"{{{MD}}}KeyDescriptor", use="signing")
x509_data = etree.SubElement(etree.SubElement(key_descriptor, f"{{{DS}}}KeyInfo"), f"{{{DS}}}X509Data")
etree.SubElement(x509_data, f"{{{DS}}}X509Certificate").text = certificate_body
etree.SubElement(idp, f"{{{MD}}}NameIDFormat").text = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
etree.SubElement(
idp,
f"{{{MD}}}SingleSignOnService",
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
Location="https://pyxecm.example.com/saml/sso-not-used",
)
Path("pyxecm-idp-metadata.xml").write_bytes(
etree.tostring(entity, xml_declaration=True, encoding="UTF-8", pretty_print=True)
)
The entityID must be the same value as saml_issuer. Use the NameIDFormat you agree
on with the SAP administrator (see step 2.3).
2. Configure SAP (SAP administrator)¶
SAP documents the procedure in "Configuring a Trusted Identity Provider for OAuth 2.0" and "Trusting an Identity Provider". Labels can differ slightly between SAP releases.
2.1 Enable SAML 2.0 (SAML2 local provider)¶
Enable SAML 2.0 for the SAP client (e.g. 100) if it is not yet
enabled. The provider name (e.g. S4HCLNT100) becomes pyxecm's saml_audience - see
Names that must match. It can be read from SAP's metadata:
curl -s "https://s4.example.com:44300/sap/saml2/sp/metadata?sap-client=100" | grep -o 'entityID="[^"]*"'
2.2 Add pyxecm as trusted provider (SAML2)¶
In transaction SAML2: Trusted Providers -> Add -> upload
pyxecm-idp-metadata.xml (or add the provider manually with saml-idp.crt as
signing certificate and pyxecm-idp as provider name). Make it available for OAuth 2.0.
2.3 Map users (SAML2 identity federation)¶
On the trusted provider, add the NameID format pyxecm sends and how SAP finds the user
for it (User ID Mapping Mode). The NameID format must be the same on both sides - in
pyxecm it is set with SAP__SAML_NAMEID_FORMAT:
NameID format (SAP__SAML_NAMEID_FORMAT) |
NameID the Content Aviator plugin sends | Typical mapping in SAP |
|---|---|---|
urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress |
E-mail address of the Content Server user | E-mail address of the SAP user (SU01) |
urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified |
Login of the Content Server user | SAP user ID, or an alias of the SAP user |
Defaults: SAP__SAML_NAMEID_FORMAT (and so the Content Aviator plugin) defaults to
emailAddress; the pyxecm methods get_saml_assertion() and
get_oauth_access_token_for_user() default to unspecified if no nameid_format is passed.
The Content Aviator plugin supports only these two formats. SAP's SAML2 offers further
SAML NameID formats (persistent, transient, X509SubjectName,
WindowsDomainQualifiedName, kerberos, entity) - pyxecm's SAP class can send any of
them, but they would need a matching user attribute on the pyxecm side.
emailAddress keeps users without an e-mail address, like technical and emergency users,
out of reach. Mapping unspecified to the SAP user ID makes every SAP user reachable
with pyxecm's key. The mapped attribute must be unique and not editable by the users
themselves - on both sides (see Security considerations).
2.4 Enable OAuth per service (/IWFND/MAINT_SERVICE)¶
Enable OAuth for every OData service
pyxecm calls. This creates the scope IDs that the OAuth client gets in
step 2.5 - typically <service>_<version>
(e.g. API_MAINTENANCEORDER_0002), but SAP may generate it with a customer namespace prefix
instead (e.g. ZAPI_MAINTENANCEORDER_0002) - note the actual IDs shown by SAP rather than
assuming the pattern. A service without OAuth has no scope: calls to it with a user's token
fail with scope_check_impossible (see Testing the setup).
2.5 Create the OAuth client (SU01, SOAUTH2)¶
SOAUTH2 (the OAuth 2.0 Client Administration transaction) does not let you invent an
arbitrary client ID/secret pair - an OAuth 2.0 client's identity in SAP must be backed by
a real user record. So this step has two parts:
SU01(SAP's standard User Maintenance transaction - SAP GUI: enterSU01in the transaction field, or the Fiori app "Maintain Users") - create a new user, e.g.PYXECM_OAUTH:- User type = "System" (not "Dialog") - it is a machine-to-machine technical identity, never used for interactive logon.
- Set a password - this becomes the OAuth client secret (used for HTTP basic authentication when pyxecm calls SAP's token endpoint).
- This user needs no business authorizations itself - it only identifies pyxecm as the calling application during client authentication. The actual business authorizations belong to the end users whose identity is propagated via the SAML assertion (see step 2.6), not to this user.
SOAUTH2- create/edit the OAuth 2.0 client with thisSU01user as client ID. Enable the grant type SAML 2.0 Bearer Assertion, select pyxecm's trusted provider as Trusted OAuth 2.0 IdP, set Client Authentication to Password, and assign only the scopes pyxecm needs (the OData services it calls).
The SU01 username becomes SAP__OAUTH_CLIENT_ID, its password becomes
SAP__OAUTH_CLIENT_SECRET (see step 3).
Worked example - the "Create OAuth 2.0 Client" wizard (field labels can differ slightly between SAP releases/Fiori apps; it has 5 steps):
- Client ID:
- Client Type = Confidential (pyxecm holds a secret - never Public).
- OAuth 2.0 Client ID = the
SU01user name from part 1 above (e.g.PYXECM_OAUTH) - client ID and client user name must be identical. - Description = any free text identifying the consuming application, e.g. "Content Aviator SAP Agent".
- Token Lifetime = the access token lifetime in seconds (SOAUTH2 setting mentioned in the checklist - at least 600s/10 minutes; the default of 3600s works well).
- Client Authentication (see the previous explanation above): enable HTTP Basic (may be labelled Client User ID and Password on some releases) and Check Parameter "client_id". SSL Client Certificate is not used by pyxecm (it only authenticates via HTTP Basic) - safe to leave checked or unchecked.
- Grant Type Settings:
- Grant Type SAML 2.0 Bearer Active = checked.
- Trusted OAuth 2.0 IdP = the entity ID of pyxecm's trusted provider from
step 2.2 (
saml_issuer, e.g.pyxecm-idp). - Requires Attribute "client_id" = leave unchecked - this requires the SAML
assertion itself to carry a
client_idattribute, whichSAP.get_saml_assertion()does not add; checking it would make every assertion fail. - Grant Type Authorization Code Active, Refresh Allowed, Grant Type Client Credentials Active = leave unchecked - none of these apply to the bearer assertion flow pyxecm uses (no interactive browser login, no refresh tokens, no client-only tokens without a propagated user).
- Scope Assignment: add the scope ID(s) created in
step 2.4 for the OData service(s) pyxecm
calls - they may carry a customer namespace prefix (e.g.
ZAPI_MAINTENANCEORDER_0002rather thanAPI_MAINTENANCEORDER_0002); pick them from the list shown here, don't type them from memory. The list only offers scopes of services with OAuth enabled - if it shows only unrelated default scopes (e.g.ADT_0001- "Gateway service for ADT", for ABAP Development Tools), step 2.4 is missing for the needed services. When pyxecm later calls additional services, enable OAuth for them (step 2.4) and add their scopes to the client here. - Summary: review all settings and Finish.
2.6 Authorize the end users (PFCG)¶
In SAP S/4HANA transaction PFCG (role maintenance, see Terminology), give the propagated
users the usual authorizations for the services (S_SERVICE) and the business
transactions behind them - and authorization object S_SCOPE with the OAuth client ID
(OA2_CLIENT, e.g. PYXECM_OAUTH) and the scope IDs (OA2_SCOPE). A user gets only the
requested scopes that are in their S_SCOPE (see How SAP grants scopes):
without any S_SCOPE for the OAuth client the token request fails with invalid_scope
("... not assigned to the current user or to the OAuth 2.0 client") although the scopes are
assigned to the client; with only some of them the token lacks the others and calls to these
services fail with HTTP 403. If principal propagation works for one user but not for
another, this is the typical cause (check with SU53).
When all steps are done, hand back to the pyxecm team: the local provider name (saml_audience), the agreed NameID
format, the OAuth client ID, the OAuth client secret (over a secure channel, see the
checklist), and the scope IDs.
3. Configure pyxecm¶
In the payload, add the saml_* fields to the SAP entry of externalSystems (see
payload syntax) - base_url must be set as well, as
principal propagation uses SAP's OData APIs. Only the file names go into the payload; mount
the key pair into the customizer pod from a secret:
saml_idp_private_key_file = "/secrets/saml-idp.key"
saml_idp_certificate_file = "/secrets/saml-idp.crt"
saml_issuer = "pyxecm-idp"
saml_audience = "S4HCLNT100"
The customizer reads them from the payload. Other consumers - e.g. the Content Aviator plugin
and the debug scripts (debug/debug_sap.py) - read the same values from the environment
variables of the SAP settings group (CustomizerSettingsSAP):
SAP__SAML_IDP_PRIVATE_KEY_FILE=/run/secrets/saml-idp.key
SAP__SAML_IDP_CERTIFICATE_FILE=/run/secrets/saml-idp.crt
SAP__SAML_ISSUER=pyxecm-idp
SAP__SAML_AUDIENCE=S4HCLNT100
SAP__SAML_NAMEID_FORMAT=urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress # default
SAP__OAUTH_CLIENT_ID=PYXECM_OAUTH # SU01 system user of the SOAUTH2 client
SAP__OAUTH_CLIENT_SECRET=... # its password - from a secret store
SAP__OAUTH_SCOPE="API_MAINTENANCEORDER_0002 API_MAINTNOTIFICATION_0001"
SAP__OAUTH_SCOPE is required for principal propagation: S/4HANA answers a token
request without scope with invalid_scope - it does not fall back to the scopes assigned
to the OAuth client. List one scope per OData service the token is used for; calls to a
service whose scope is not granted fail (see How SAP grants scopes).
How SAP grants scopes¶
SAP grants the intersection of three scope sets - the token carries only the scopes that are in all of them:
- the scopes requested by pyxecm (
scopeparameter,SAP__OAUTH_SCOPE), - the scopes assigned to the OAuth client (
SOAUTH2, step 2.5), and - the scopes the SAP user is authorized for (
S_SCOPEin the user's roles, step 2.6).
Example - pyxecm requests API_MAINTENANCEORDER_0002 API_MAINTNOTIFICATION_0001 API_SALES_ORDER_SRV_0001,
all three are assigned to the OAuth client:
The user's S_SCOPE covers |
Result |
|---|---|
| All three scopes | Token with all three scopes |
API_SALES_ORDER_SRV_0001 only |
Token with API_SALES_ORDER_SRV_0001 - the other two are dropped silently |
None of them (or no S_SCOPE for the OAuth client) |
No token - invalid_scope |
The same applies to requested scopes that are not assigned to the OAuth client or don't exist at all: they are dropped silently as long as at least one requested scope is granted.
What this means:
invalid_scopemeans the intersection is empty - none of the requested scopes can be granted to this user with this OAuth client (or noscopewas sent). It does not mean that one of several requested scopes is missing.- A partly missing scope is not reported when the token is issued. The token is issued
with fewer scopes, and only calls to the services whose scopes are missing fail later -
with HTTP
403(insufficient_scope, see Testing the setup). - One scope list fits all users. Request the scopes of all services pyxecm may call
(e.g. one
SAP__OAUTH_SCOPEfor all agents of the Content Aviator plugin); each user gets the subset their roles allow - e.g. a sales user only the sales scopes. There is no need to request user- or role-specific scopes. - Compare the granted scopes to find missing ones. The token response lists the granted
scopes (
scope, space-separated). The difference to the requested scopes is exactly what the SOAUTH2 client or the user'sS_SCOPElacks - one token request per user is enough to check a user's setup. - A granted scope is not the whole authorization. The user still needs
S_SERVICEfor the service (OData V4: the service group) and the business authorizations (e.g. sales organization, plant). WithoutS_SERVICEthe call fails with HTTP403despite the scope.
Or directly (requires the saml extra: pip install "pyxecm[saml]"):
from pathlib import Path
from pyxecm_customizer.sap import SAP
sap = SAP(
username="", # no technical user needed for principal propagation
password="",
system_id="S4H",
client="100",
base_url="https://s4.example.com:44300",
saml_idp_private_key=Path("/run/secrets/saml-idp.key").read_text(),
saml_idp_certificate=Path("/run/secrets/saml-idp.crt").read_text(),
saml_issuer="pyxecm-idp", # entity ID of the trusted provider in SAML2
saml_audience="S4HCLNT100", # SAML2 local provider name
)
The constructor checks the configuration (key loadable, RSA with at least 2048 bits,
certificate matches the key and is not expired, issuer and audience set). If something is
wrong, it logs an error and get_saml_assertion() returns None with the reason in
last_error. The expiry of the certificate is also checked before each assertion (see
Operations).
Usage¶
Getting a token for a user and calling SAP with it takes two SAP objects: the identity provider (with the key pair, see above) and one SAP object per user that carries the user's token. A user needs an SAP object of their own because SAP binds its session cookies and CSRF tokens to the user - a shared session could run requests as the wrong user.
from pyxecm_customizer.sap import SAP, BearerAuth
token = identity_provider.get_oauth_access_token_for_user(
user_id="jane.smith@example.com", # from the verified user session - never from request input
nameid_format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
client_id="PYXECM_OAUTH",
client_secret="...", # from a secret store
scope="API_MAINTENANCEORDER_0002 API_MAINTNOTIFICATION_0001",
)
if token is None:
raise RuntimeError(identity_provider.last_error)
user_sap = SAP(
username="", # no technical user - all OData calls run as the user of the token
password="",
system_id="S4H",
client="100",
base_url="https://s4.example.com:44300",
auth=BearerAuth(token["access_token"]),
)
orders = user_sap.get_maintenance_orders()
get_oauth_access_token_for_user() is get_saml_assertion() followed by
get_oauth_access_token(); the result is SAP's token response (access_token,
token_type, expires_in, scope).
- Where the user ID comes from is the security boundary of this design. Take it from a
session the application has validated - e.g. the e-mail address of the user's OTCS ticket
(Content Aviator plugin) or the
pyxecm_apiOTDS ticket check - never from tool arguments or request parameters. - Token lifetime:
BearerAuthdoes not renew the token. The token expires afterexpires_inseconds (SOAUTH2 setting, usually 3600) and cannot be revoked by pyxecm. Cache the per-user SAP objects for a shorter time than that and create a new one (with a new token) afterwards - a new assertion is cheap. If SAP rejects the token anyway (HTTP401, e.g. the SOAUTH2 lifetime is shorter than the cache time or the SAP user was locked), the call returnsNone/Falseandlast_errorsays that the access token was rejected - discard the user's SAP object then. - RFC calls (PyRFC) are not affected - they always use the technical user.
Example - Content Aviator plugin: get_sap_object_from_user() of the plugin implements
exactly this. It creates the identity provider once from the SAP__SAML_* settings, gets a
token for the e-mail address of the user in the tool call context, and caches the user's SAP
object for 5 minutes (well below the token lifetime). Users without an e-mail address in
Content Server get an error instead of a fallback to another identity.
Testing the setup¶
The SAP class has three check_*() methods that each verify one layer of the setup
without requiring a full application - run them from a Python shell (or debug/debug_sap.py)
against real connection parameters to see what is configured and what is still missing. Each
returns a status model with a message (what SAP answered) and a hint (what to fix) - run
them in order, as a later step only makes sense once the earlier ones pass:
| Method | Verifies | Needs |
|---|---|---|
SAP.check_odata_apis() |
Section A: the OData services are activated, their ICF nodes are active and the (technical user's) credentials work | base_url + username/password (or an existing OAuth token) |
SAP.check_oauth_token_endpoint() |
Section B, infrastructure only: SAP's OAuth 2.0 token endpoint itself is activated and reachable | Only base_url - no OAuth client or SAML trust needed yet |
SAP.check_principal_propagation() |
Section B, end-to-end: a SAML assertion is minted and actually exchanged for an access token for a real SAP user | saml_idp_private_key/saml_issuer/saml_audience (identity provider) + a working OAuth client + SAML2 trust |
from pyxecm_customizer.sap import SAP
# 1. OData layer (section A) - technical user, independent of principal propagation:
sap = SAP(username="...", password="...", system_id="S4H", client="100", base_url="https://s4.example.com:44300")
for name, status in sap.check_odata_apis().items():
print(name, "->", "OK" if status.active else "{} ({})".format(status.state.value, status.hint))
# 2. OAuth token endpoint (section B, infrastructure) - no OAuth client needed:
status = sap.check_oauth_token_endpoint()
print("token endpoint ->", "OK" if status.ok else "{} - {}".format(status.message, status.hint))
# 3. Full principal propagation round trip (section B, end-to-end) - needs the identity
# provider's key pair and a working OAuth client. Use a dedicated test user:
identity_provider = SAP(
username="...",
password="...",
system_id="S4H",
base_url="https://s4.example.com:44300",
saml_idp_private_key=open("saml-idp.key").read(),
saml_idp_certificate=open("saml-idp.crt").read(),
saml_issuer="pyxecm-idp",
saml_audience="S4HCLNT100",
)
status = identity_provider.check_principal_propagation(
user_id="jane.smith@example.com",
nameid_format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
client_id="PYXECM_OAUTH",
client_secret="...",
scope="API_MAINTENANCEORDER_0002",
)
print("principal propagation ->", "OK" if status.ok else "{} - {}".format(status.message, status.hint))
Combine the three into a small health report - e.g. for a startup check or a support script:
from pyxecm_customizer.sap import SAML_NAMEID_FORMAT_EMAIL, SAP
def sap_health_report(sap: SAP, test_user: str | None = None, client_id: str = "", client_secret: str = "") -> bool:
"""Print which parts of the SAP setup (OData, OAuth endpoint, principal propagation) work."""
odata_statuses = sap.check_odata_apis() # empty without a base URL
odata_ok = bool(odata_statuses) and all(status.active for status in odata_statuses.values())
token_ok = sap.check_oauth_token_endpoint().ok
oauth_ok = (
sap.check_principal_propagation(
user_id=test_user,
client_id=client_id,
client_secret=client_secret,
nameid_format=SAML_NAMEID_FORMAT_EMAIL, # as configured in SAML2 (identity federation)
).ok
if test_user
else None
)
print("OData services:", "OK" if odata_ok else "FAILED")
print("OAuth token endpoint:", "OK" if token_ok else "FAILED")
if oauth_ok is not None:
print("Principal propagation:", "OK" if oauth_ok else "FAILED")
return odata_ok and token_ok and (oauth_ok is not False)
Each check is independent and safe to run repeatedly - none of them creates, changes or
deletes business data. check_principal_propagation() gets a real access token (SAP records it like any
other token, e.g. in the security audit log), but never calls an OData service with it.
check_oauth_token_endpoint() deliberately sends neither client credentials nor grant_type. A live
endpoint answers with HTTP 401 (its ICF node authenticates the OAuth client first) or a
generic OAuth error (e.g. invalid_request) - either proves the endpoint is active, before
any client or trust is set up. HTTP 404, or 403 "Service cannot be reached", means the ICF
node /sap/bc/sec/oauth2/token is not active in SICF.
check_principal_propagation() mints the assertion first and sends nothing to SAP if that fails - a
not_configured state therefore always means pyxecm's own identity provider settings, never
the SAP side. Pass recipient if SAP is reached via a reverse proxy, exactly as for
get_oauth_access_token_for_user().
A token from check_principal_propagation() only proves that SAP issues it - to verify that
a user can actually call the OData services with it, run check_odata_apis() with the user's
token (section A + B combined, read-only):
from pyxecm_customizer.sap import SAP, BearerAuth
token = identity_provider.get_oauth_access_token_for_user(
user_id="jane.smith@example.com",
nameid_format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
client_id="PYXECM_OAUTH",
client_secret="...",
scope="API_MAINTENANCEORDER_0002 API_MAINTNOTIFICATION_0001",
)
user_sap = SAP(username="", password="", system_id="S4H", client="100",
base_url="https://s4.example.com:44300", auth=BearerAuth(token["access_token"]))
for name, status in user_sap.check_odata_apis().items():
print(name, "->", "OK" if status.active else "{} - {}".format(status.message, status.hint))
With a token, SAP answers OAuth problems with a generic HTML error page and names the actual
cause in the WWW-Authenticate response header. check_odata_apis() evaluates it:
WWW-Authenticate error |
State | Meaning |
|---|---|---|
scope_check_impossible |
not_activated |
OAuth is not enabled for the service (step 2.4) - it has no scope yet |
insufficient_scope |
forbidden |
The token does not carry the service's scope - add it to SAP__OAUTH_SCOPE, SOAUTH2 and S_SCOPE |
invalid_token |
unauthorized |
The token is invalid or expired - get a new one |
Operations¶
- Key or certificate change: the identity provider checks key and certificate once, in the constructor. After replacing the secret, restart the pods (or create a new identity provider object) - until then the old key is used, or a configuration error stays.
- Certificate expiry: pyxecm warns at startup 30 days before. Once the certificate has
expired,
get_saml_assertion()refuses to sign - also in a long-running pod - andlast_errorsays "saml_idp_certificate expired on ...". Renew the key pair, update the trust in SAP S/4HANA transactionSAML2and restart. This only covers the certificate pyxecm holds: if the certificate in SAP S/4HANA transactionSAML2is removed or replaced, SAP answersinvalid_grant. - Deprovisioning: a user locked in OTDS or Content Server keeps SAP access until the caches expire (Content Aviator plugin: up to 5 minutes for the user information plus up to 5 minutes for the SAP object). SAP access tokens cannot be revoked by pyxecm.
- Sessions: the SAP object keeps one SAP session per thread. If SAP ends a session (HTTP
401with session cookies),do_request()starts a new one and repeats the request once. A401without session cookies is not repeated - a wrong password would otherwise count twice towards locking the SAP user.
Verified behavior¶
Tested against an OTDS instance and an S/4HANA 2023 test system (SAP_BASIS 7.58), October 2026:
| Topic | Result |
|---|---|
| Assertion format | Valid against the OASIS SAML 2.0 assertion schema; signature verifies, a changed NameID or a foreign certificate fails verification. |
| SAP token endpoint | Active. Without a registered OAuth client it answers 401 invalid_client (the client is checked before the assertion). |
| SAP SAML 2.0 / OAuth support | SAML 2.0 Bearer grant present (CL_SAML20_BEARER_INBOUND); no inbound JWT Bearer grant. SAML 2.0 was not yet enabled in the tested client. |
| OTDS as SAML IdP | Not possible: SAML metadata has only an SPSSODescriptor; token exchange rejects requested_token_type saml2, saml1 and jwt. |
| OTDS JWT sent to SAP OData | 401, SAP offers only Basic in WWW-Authenticate. |
OTDS token exchange (impersonate_user()) |
Works with a confidential OAuth client (client_credentials); a public client gets invalid_request / wrong_client_type. |
| OTDS token claims | uid = login@partition, sub = the user's GUID, iss = {OTDS_URL}/otdsws, RS256 (kid from the JWKS). |
| Impersonation list of an OTDS client | Restricts the resources the tokens can be used with - not which users can be impersonated. |
Alternatives¶
| Alternative | Assessment |
|---|---|
| OTDS as SAML IdP for SAP | Not available (see Verified behavior). |
| OTDS JWT via JWT Bearer grant / JWKS trust | Not supported by SAP_BASIS 7.58. |
| Corporate IdP (e.g. Microsoft Entra ID) | Entra ID's on-behalf-of flow can return a SAML assertion for SAP - no key in pyxecm, but the users must sign on via Entra ID and pyxecm must hold their Entra token. |
| SAP BTP principal propagation | SAP's standard pattern (destination type OAuth2SAMLBearerAssertion), requires an SAP BTP subaccount. |
| X.509 client certificates per user | Works for OData and RFC, but needs a CA operated by pyxecm - the same key-protection problem, more infrastructure. |
OpenID Connect logon with OTDS (transaction OIDC) |
SAP_BASIS 7.58 can trust OTDS directly for browser logon (Fiori) - interactive only, not for API calls. |
Fallback: technical user¶
Without principal propagation, the SAP class uses HTTP basic authentication with one
technical user (username/password). OTDS then controls only who may trigger an
action in pyxecm (e.g. the OTDS ticket check of pyxecm_api); SAP checks the technical
user's authorizations and records it as the author of all changes.
Security considerations¶
- pyxecm's IdP key is equivalent to a master key for every SAP user the NameID mapping
reaches. Keep it in a secret store, mount it read-only into the pyxecm container only,
rotate the certificate regularly (pyxecm warns 30 days before expiry) and remove a
compromised certificate from SAP S/4HANA transaction
SAML2immediately. - Limit who can be reached on the SAP side: map NameIDs to e-mail addresses or aliases instead of SAP user IDs, so technical, emergency and administrator users without these attributes cannot be propagated.
- Limit what can be done: give the SOAUTH2 client only the scopes of the services pyxecm uses. The propagated user's own authorizations apply in addition.
- Never mint assertions for unverified user IDs. The user ID must come from an authenticated session (OTDS session or Content Server ticket), not from request parameters or LLM tool arguments.
- The mapping attribute is the security boundary. Whoever can change it controls which
SAP user an assertion stands for. With e-mail mapping: if users (or partition
administrators) can change their e-mail address in Content Server or OTDS, they can become
any SAP user with that address. On the SAP side, users can change their own e-mail
address (
SU3) andSU01does not enforce unique addresses. So make the attribute read-only for users on both sides (e.g. synchronized from the corporate directory) and unique - or map a dedicated, administrator-maintained attribute (SAP alias) instead. - Short lifetimes: assertions are valid for 120 seconds by default (at most 300). SAP access tokens should not be longer-lived than needed (SOAUTH2 client settings).
- Audit: pyxecm logs every minted assertion with its ID, user, audience and expiry; SAP
logs the token issuance (security audit log,
SEC_TRACE_ANALYZERfor the SAML/OAuth trace). - OTDS OAuth clients with impersonation can get a token for any OTDS user - OTDS has
no per-user restriction. Use a dedicated confidential client per service, keep its secret
in a secret store, and restrict its tokens to the needed resources (
impersonation_listofadd_oauth_client()/impersonate_oauth_client()). - SAP's ticket certificate (Direction 1) is not secret, but transfer it on a trusted channel so OTDS does not trust a forged certificate.
Troubleshooting¶
get_oauth_access_token() returns None and puts SAP's OAuth error into last_error:
| SAP error | Typical cause |
|---|---|
invalid_client |
Wrong OAuth client ID or secret, client user locked, or the client does not exist in this SAP client (sap-client). |
invalid_grant |
The assertion was rejected: unknown issuer or certificate (SAML2 trusted provider), wrong audience (must be the local provider name), expired (clock difference > 60 s), wrong recipient (reverse proxy - pass recipient), or no SAP user found for the NameID. |
unauthorized_client |
The SAML 2.0 Bearer grant is not enabled for the OAuth client, or pyxecm's provider is not its trusted OAuth 2.0 IdP. |
invalid_scope |
None of the requested scopes can be granted: the user has no S_SCOPE for the OAuth client, none of the scopes is assigned to the OAuth client, OAuth is not enabled for the services (/IWFND/MAINT_SERVICE), or no scope was sent. Scopes that are only partly missing don't cause this error - see How SAP grants scopes. |
OData calls with an access token that fail with HTTP 401 (last_error: "SAP rejected the
access token") mean the token has expired or the SAP user is locked - get a new token.
SAP's invalid_grant messages are deliberately vague. The exact reason is in SAP's
security trace (transaction SEC_TRACE_ANALYZER, components SAML 2.0 and OAuth 2.0) -
search it for the assertion ID that pyxecm logged.