"""Authentication strategies for the SAP SuccessFactors OData API. Supports: * **Basic Auth** — username (``userId@companyId``) + password. Simple, suitable for local dev, deprecated by SAP for production use. * **OAuth 2.0 SAML Bearer Assertion** — SAP's recommended production flow. A short-lived SAML 2.0 assertion is RSA-signed with the private key corresponding to the X.509 cert registered in SF Admin Center > Manage OAuth2 Client Applications, then exchanged for a bearer access token at the configured token endpoint. The client holds exactly one auth instance and calls ``apply()`` for every request to attach credentials (headers / auth tuple). The OAuth implementation caches the access token in-memory until expiry so steady-state sync traffic doesn't re-sign on every call. """ from __future__ import annotations import base64 import logging import secrets import uuid from abc import ABC, abstractmethod from datetime import UTC, datetime, timedelta from typing import Any import httpx logger = logging.getLogger(__name__) # ── Base interface ────────────────────────────────────────────────── class SuccessFactorsAuth(ABC): """Pluggable auth strategy for ``SuccessFactorsClient``.""" @abstractmethod async def apply(self, request: httpx.Request) -> None: """Mutate ``request`` in place to attach credentials.""" def describe(self) -> dict[str, Any]: """Short, non-secret description for audit / error messages.""" return {"type": type(self).__name__} # ── Basic Auth ────────────────────────────────────────────────────── class BasicAuth(SuccessFactorsAuth): """HTTP Basic Auth. SF requires ``username`` to already be in ``userId@companyId`` format — we don't synthesize it, the admin pastes the full form. """ def __init__(self, username: str, password: str) -> None: if not username or not password: raise ValueError("BasicAuth requires non-empty username and password") self._header = ( "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode() ) self._username_preview = username async def apply(self, request: httpx.Request) -> None: request.headers["Authorization"] = self._header def describe(self) -> dict[str, Any]: return {"type": "basic", "username": self._username_preview} # ── OAuth 2.0 SAML Bearer Assertion ───────────────────────────────── class OAuth2SamlBearerAuth(SuccessFactorsAuth): """OAuth 2.0 with SAML 2.0 Bearer Assertion grant. Token lifecycle: 1. Build unsigned ```` naming ``api_key`` as audience, ``technical_user_id`` as subject NameID. 2. Enveloped-signature sign the assertion with ``private_key`` using RSA-SHA256 and exclusive C14N (per SAP spec). 3. Base64-encode the signed XML. 4. POST ``company_id`` + ``client_id`` + ``assertion`` to the configured token endpoint with ``grant_type=urn:ietf: params:oauth:grant-type:saml2-bearer``. 5. Cache ``access_token`` until ``expires_in`` elapses. """ _ASSERTION_TTL_SECONDS = 600 # assertion validity window (10 min) _TOKEN_REFRESH_LEEWAY_SECONDS = 60 # refresh slightly early def __init__( self, *, token_url: str, company_id: str, api_key: str, technical_user_id: str, private_key_pem: str, ) -> None: if not all([token_url, company_id, api_key, technical_user_id, private_key_pem]): raise ValueError("OAuth2SamlBearerAuth missing required field") self.token_url = token_url self.company_id = company_id self.api_key = api_key self.technical_user_id = technical_user_id self.private_key_pem = private_key_pem.strip() self._access_token: str | None = None self._access_token_expires_at: datetime | None = None async def apply(self, request: httpx.Request) -> None: token = await self._get_access_token() request.headers["Authorization"] = f"Bearer {token}" def describe(self) -> dict[str, Any]: return { "type": "oauth2_saml", "token_url": self.token_url, "company_id": self.company_id, "technical_user_id": self.technical_user_id, } async def _get_access_token(self, *, force_refresh: bool = False) -> str: now = datetime.now(UTC) if ( not force_refresh and self._access_token and self._access_token_expires_at and now < self._access_token_expires_at - timedelta( seconds=self._TOKEN_REFRESH_LEEWAY_SECONDS ) ): return self._access_token signed_assertion_b64 = build_signed_saml_assertion( client_id=self.api_key, technical_user_id=self.technical_user_id, token_url=self.token_url, private_key_pem=self.private_key_pem, ttl_seconds=self._ASSERTION_TTL_SECONDS, ) from app.integrations._url_safety import safe_async_http_transport transport = await safe_async_http_transport(self.token_url) async with httpx.AsyncClient( timeout=30.0, trust_env=False, follow_redirects=False, transport=transport, ) as client: response = await client.post( self.token_url, data={ "grant_type": "urn:ietf:params:oauth:grant-type:saml2-bearer", "company_id": self.company_id, "client_id": self.api_key, "assertion": signed_assertion_b64, }, ) if response.status_code >= 400: # Do NOT log response.text — SF token endpoint error bodies # can echo back the assertion / one-time-code / client_secret # hint. Keep diagnostics to status code + content type + # body length so logs are still actionable without # credential surface. logger.warning( "SF token endpoint returned %s (body suppressed; len=%d, content_type=%s)", response.status_code, len(response.text or ""), (response.headers or {}).get("content-type"), ) raise RuntimeError( f"SF token endpoint returned {response.status_code} " "(body suppressed to avoid credential echo)." ) payload = response.json() access_token = payload.get("access_token") if not access_token: raise RuntimeError( "SuccessFactors token response missing access_token " "(body suppressed to avoid credential echo)." ) expires_in = int(payload.get("expires_in", 3600)) self._access_token = access_token self._access_token_expires_at = now + timedelta(seconds=expires_in) return access_token # ── OAuth 2.0 Client Credentials (SAP IAS + resource-scoped) ─────── class OAuth2ClientCredentialsAuth(SuccessFactorsAuth): """OAuth 2.0 client_credentials grant against SAP Identity Authentication Service (IAS) — the token flow SF D-environments use when fronted by IAS with a resource-scoped application provider. Unlike the SAML Bearer flow (which requires an X.509 cert + signed assertion per request), this exchanges a static client_id / client_secret for a bearer token that the caller then sends to the OData endpoint. Shape of request:: POST https://.accounts.sapcloud.cn/oauth2/token Content-Type: application/x-www-form-urlencoded grant_type=client_credentials &client_id=... &client_secret=... &resource=urn:sap:identity:application:provider:name:SF_XXX Some IAS tenants also accept HTTP Basic for the client creds — we send both form-encoded and a Basic header so either server configuration works. Response is standard OAuth (access_token + expires_in). The token is cached in-memory until expiry minus a small leeway. """ _TOKEN_REFRESH_LEEWAY_SECONDS = 60 def __init__( self, *, token_url: str, client_id: str, client_secret: str, resource: str | None = None, scope: str | None = None, ) -> None: if not all([token_url, client_id, client_secret]): raise ValueError( "OAuth2ClientCredentialsAuth requires token_url, " "client_id, and client_secret" ) self.token_url = token_url self.client_id = client_id self.client_secret = client_secret self.resource = (resource or "").strip() or None self.scope = (scope or "").strip() or None self._access_token: str | None = None self._access_token_expires_at: datetime | None = None async def apply(self, request: httpx.Request) -> None: token = await self._get_access_token() request.headers["Authorization"] = f"Bearer {token}" def describe(self) -> dict[str, Any]: return { "type": "oauth2_client_credentials", "token_url": self.token_url, "client_id": self.client_id, "resource": self.resource, "scope": self.scope, } async def _get_access_token(self, *, force_refresh: bool = False) -> str: now = datetime.now(UTC) if ( not force_refresh and self._access_token and self._access_token_expires_at and now < self._access_token_expires_at - timedelta( seconds=self._TOKEN_REFRESH_LEEWAY_SECONDS ) ): return self._access_token data: dict[str, str] = { "grant_type": "client_credentials", "client_id": self.client_id, "client_secret": self.client_secret, } if self.resource: data["resource"] = self.resource if self.scope: data["scope"] = self.scope basic_header = "Basic " + base64.b64encode( f"{self.client_id}:{self.client_secret}".encode() ).decode() from app.integrations._url_safety import safe_async_http_transport transport = await safe_async_http_transport(self.token_url) async with httpx.AsyncClient( timeout=30.0, trust_env=False, follow_redirects=False, transport=transport, ) as client: response = await client.post( self.token_url, data=data, headers={ "Authorization": basic_header, "Accept": "application/json", }, ) if response.status_code >= 400: # See SAML-bearer flow above: never echo the body — SAP IAS # token errors can include client_secret hints or one-time # codes that themselves act as credentials. logger.warning( "SF IAS token endpoint returned %s (body suppressed; len=%d, content_type=%s)", response.status_code, len(response.text or ""), (response.headers or {}).get("content-type"), ) raise RuntimeError( f"SAP IAS token endpoint returned {response.status_code} " "(body suppressed to avoid credential echo)." ) payload = response.json() access_token = payload.get("access_token") if not access_token: raise RuntimeError( "SAP IAS client_credentials response missing access_token " "(body suppressed to avoid credential echo)." ) expires_in = int(payload.get("expires_in", 3600)) self._access_token = access_token self._access_token_expires_at = now + timedelta(seconds=expires_in) return access_token # ── SAML assertion builder + signer ───────────────────────────────── def build_signed_saml_assertion( *, client_id: str, technical_user_id: str, token_url: str, private_key_pem: str, ttl_seconds: int = 600, ) -> str: """Build, sign, and base64-encode a SAML 2.0 Bearer assertion for SF. Returns the ``base64.b64encode()`` string expected as the ``assertion=`` form parameter at the token endpoint. Keeps everything the signed XML — minimal enveloped signature with exclusive C14N (``xml-exc-c14n``) + RSA-SHA256, matching the SAP SuccessFactors OAuth 2.0 spec. """ # Local imports so `signxml` only required when OAuth is actually used. from lxml import etree from signxml import XMLSigner, methods now = datetime.now(UTC) not_before = now - timedelta(seconds=60) # small clock skew buffer not_on_or_after = now + timedelta(seconds=ttl_seconds) assertion_id = f"_{uuid.uuid4().hex}" issue_instant = now.strftime("%Y-%m-%dT%H:%M:%SZ") not_before_str = not_before.strftime("%Y-%m-%dT%H:%M:%SZ") not_on_or_after_str = not_on_or_after.strftime("%Y-%m-%dT%H:%M:%SZ") nsmap = { "saml2": "urn:oasis:names:tc:SAML:2.0:assertion", } assertion = etree.Element( "{urn:oasis:names:tc:SAML:2.0:assertion}Assertion", nsmap=nsmap, attrib={ "ID": assertion_id, "IssueInstant": issue_instant, "Version": "2.0", }, ) issuer = etree.SubElement( assertion, "{urn:oasis:names:tc:SAML:2.0:assertion}Issuer", ) # SF uses the API key (client_id) as the Issuer issuer.text = client_id subject = etree.SubElement( assertion, "{urn:oasis:names:tc:SAML:2.0:assertion}Subject", ) name_id = etree.SubElement( subject, "{urn:oasis:names:tc:SAML:2.0:assertion}NameID", Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", ) name_id.text = technical_user_id subject_confirmation = etree.SubElement( subject, "{urn:oasis:names:tc:SAML:2.0:assertion}SubjectConfirmation", Method="urn:oasis:names:tc:SAML:2.0:cm:bearer", ) etree.SubElement( subject_confirmation, "{urn:oasis:names:tc:SAML:2.0:assertion}SubjectConfirmationData", NotOnOrAfter=not_on_or_after_str, Recipient=token_url, ) conditions = etree.SubElement( assertion, "{urn:oasis:names:tc:SAML:2.0:assertion}Conditions", NotBefore=not_before_str, NotOnOrAfter=not_on_or_after_str, ) audience_restriction = etree.SubElement( conditions, "{urn:oasis:names:tc:SAML:2.0:assertion}AudienceRestriction", ) audience = etree.SubElement( audience_restriction, "{urn:oasis:names:tc:SAML:2.0:assertion}Audience", ) audience.text = "www.successfactors.com" auth_statement = etree.SubElement( assertion, "{urn:oasis:names:tc:SAML:2.0:assertion}AuthnStatement", AuthnInstant=issue_instant, SessionIndex=f"_{secrets.token_hex(16)}", ) authn_context = etree.SubElement( auth_statement, "{urn:oasis:names:tc:SAML:2.0:assertion}AuthnContext", ) authn_context_class_ref = etree.SubElement( authn_context, "{urn:oasis:names:tc:SAML:2.0:assertion}AuthnContextClassRef", ) authn_context_class_ref.text = ( "urn:oasis:names:tc:SAML:2.0:ac:classes:PreviousSession" ) signer = XMLSigner( method=methods.enveloped, signature_algorithm="rsa-sha256", digest_algorithm="sha256", c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#", ) # Only sign the Assertion element, put signature right after Issuer signer.namespaces = {"ds": "http://www.w3.org/2000/09/xmldsig#"} signed_assertion = signer.sign( assertion, key=private_key_pem.encode(), reference_uri=assertion_id, ) xml_bytes = etree.tostring( signed_assertion, xml_declaration=False, encoding="utf-8", ) return base64.b64encode(xml_bytes).decode() # ── Factory ───────────────────────────────────────────────────────── def build_auth_from_config(config: dict[str, Any]) -> SuccessFactorsAuth: """Construct an auth strategy from a merged platform_config dict. Raises ``ValueError`` if required fields for the selected auth method are missing. Callers should translate that to a 503 / clear UI error so the admin knows to complete their configuration. """ auth_method = (config.get("auth_method") or "basic").strip() if auth_method == "basic": username = (config.get("username") or "").strip() password = config.get("password") or "" if not username or not password: raise ValueError( "SuccessFactors basic auth requires username and password" ) return BasicAuth(username=username, password=password) if auth_method == "oauth2_client_credentials": missing = [ k for k in ("token_url", "client_id", "client_secret") if not (config.get(k) or "").strip() ] if missing: raise ValueError( "SuccessFactors OAuth 2.0 client_credentials missing fields: " + ", ".join(missing) ) return OAuth2ClientCredentialsAuth( token_url=config["token_url"].strip(), client_id=config["client_id"].strip(), client_secret=config["client_secret"], resource=config.get("resource"), scope=config.get("scope"), ) if auth_method == "oauth2_saml": missing = [ k for k in ( "token_url", "company_id", "api_key", "technical_user_id", "private_key", ) if not config.get(k) ] if missing: raise ValueError( "SuccessFactors OAuth 2.0 SAML Bearer missing fields: " + ", ".join(missing) ) return OAuth2SamlBearerAuth( token_url=config["token_url"].strip(), company_id=config["company_id"].strip(), api_key=config["api_key"].strip(), technical_user_id=config["technical_user_id"].strip(), private_key_pem=config["private_key"], ) raise ValueError(f"Unknown SuccessFactors auth_method: {auth_method!r}") __all__ = [ "BasicAuth", "OAuth2ClientCredentialsAuth", "OAuth2SamlBearerAuth", "SuccessFactorsAuth", "build_auth_from_config", "build_signed_saml_assertion", ]