From 8dc92fceec94955d0594823836d38d7da5d3a1b3 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Thu, 2 Apr 2026 11:12:43 -0300 Subject: [PATCH 1/7] feat: IAS module with token parsing --- pyproject.toml | 3 +- src/sap_cloud_sdk/ias/__init__.py | 22 ++++ src/sap_cloud_sdk/ias/_token.py | 159 ++++++++++++++++++++++++++++ src/sap_cloud_sdk/ias/exceptions.py | 5 + src/sap_cloud_sdk/ias/user-guide.md | 73 +++++++++++++ tests/ias/__init__.py | 0 tests/ias/unit/__init__.py | 0 tests/ias/unit/test_token.py | 124 ++++++++++++++++++++++ uv.lock | 11 ++ 9 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/sap_cloud_sdk/ias/__init__.py create mode 100644 src/sap_cloud_sdk/ias/_token.py create mode 100644 src/sap_cloud_sdk/ias/exceptions.py create mode 100644 src/sap_cloud_sdk/ias/user-guide.md create mode 100644 tests/ias/__init__.py create mode 100644 tests/ias/unit/__init__.py create mode 100644 tests/ias/unit/test_token.py diff --git a/pyproject.toml b/pyproject.toml index 9334cbf..4401b99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,8 @@ dependencies = [ "opentelemetry-exporter-otlp-proto-grpc~=1.38.0", "opentelemetry-exporter-otlp-proto-http~=1.38.0", "opentelemetry-processor-baggage~=0.61b0", - "traceloop-sdk~=0.52.0" + "traceloop-sdk~=0.52.0", + "PyJWT~=2.10.1", ] [build-system] diff --git a/src/sap_cloud_sdk/ias/__init__.py b/src/sap_cloud_sdk/ias/__init__.py new file mode 100644 index 0000000..88df177 --- /dev/null +++ b/src/sap_cloud_sdk/ias/__init__.py @@ -0,0 +1,22 @@ +"""SAP Cloud SDK for Python - IAS module + +Utilities for parsing SAP Identity Authentication Service (IAS) JWT tokens. + +Usage: + from sap_cloud_sdk.ias import parse_token, IASClaims + + claims = parse_token(request.headers["Authorization"]) + print(claims.app_tid) # tenant ID (multitenant scenarios) + print(claims.scim_id) # SCIM-based user ID + print(claims.sub) # OIDC subject identifier + print(claims.email) # user email (when email scope requested) +""" + +from sap_cloud_sdk.ias._token import IASClaims, parse_token +from sap_cloud_sdk.ias.exceptions import IASTokenError + +__all__ = [ + "IASClaims", + "parse_token", + "IASTokenError", +] diff --git a/src/sap_cloud_sdk/ias/_token.py b/src/sap_cloud_sdk/ias/_token.py new file mode 100644 index 0000000..89fb16b --- /dev/null +++ b/src/sap_cloud_sdk/ias/_token.py @@ -0,0 +1,159 @@ +"""IAS JWT token parsing. + +Decodes SAP IAS JWT tokens without signature verification and maps +all standard IAS claims to a typed dataclass. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional, Union + +import jwt + +from sap_cloud_sdk.ias.exceptions import IASTokenError + + +@dataclass +class IASClaims: + """Typed representation of SAP IAS JWT token claims. + + All fields are optional — a claim absent from the token is None. + No interpretation of which field represents a "global user ID" is + made by the SDK; use whichever claim fits your use case. + + Attributes: + app_tid: SAP claim identifying the tenant of the application. + Used in multitenant scenarios (e.g. subscribed BTP applications). + at_hash: Hash of the access token. Can be used to bind the ID token + to an access token and prevent token substitution attacks. + aud: Audience — recipient(s) of the token. Can be a string or list + of client IDs. + auth_time: Time when the user authenticated (seconds since Unix epoch). + azp: Authorized party — client ID to which the ID token was issued. + email: Email address of the user. Included when the email scope is + requested. + email_verified: Whether the email address has been verified. + exp: Expiration time after which the token must not be accepted + (seconds since Unix epoch). + family_name: Surname (last name) of the user. Included when the + profile scope is requested. + given_name: Given name (first name) of the user. Included when the + profile scope is requested. + groups: Groups the user belongs to, associated with authorizations. + Included when the groups scope is requested. + ias_apis: SAP claim listing API permission groups or a fixed value + when all APIs of an application are consumed. + ias_iss: SAP claim identifying the SAP tenant even when the token was + issued from a custom domain in a non-SAP domain. + iat: Time when the token was issued (seconds since Unix epoch). + iss: Issuer of the token, typically a URL such as + https://.accounts.ondemand.com. + jti: Unique identifier for the JWT, used to prevent replay attacks. + Included when the profile scope is requested. + middle_name: Middle name of the user. + name: Full display name of the user. + nonce: String associated with the client session to mitigate replay + attacks. + preferred_username: Human-readable display name / username of the user. + sap_id_type: SAP claim identifying the type of token. + ``"app"`` for application credentials, ``"user"`` for user + credentials. + scim_id: SAP claim identifying the user by their SCIM ID in SAP Cloud + Identity Services. + sid: Session ID used to track a user session across applications and + logout scenarios. + sub: Subject — unique identifier for the user, scoped to the issuer. + """ + + app_tid: Optional[str] = None + at_hash: Optional[str] = None + aud: Optional[Union[str, List[str]]] = None + auth_time: Optional[int] = None + azp: Optional[str] = None + email: Optional[str] = None + email_verified: Optional[bool] = None + exp: Optional[int] = None + family_name: Optional[str] = None + given_name: Optional[str] = None + groups: Optional[List[str]] = None + ias_apis: Optional[Union[str, List[str]]] = None + ias_iss: Optional[str] = None + iat: Optional[int] = None + iss: Optional[str] = None + jti: Optional[str] = None + middle_name: Optional[str] = None + name: Optional[str] = None + nonce: Optional[str] = None + preferred_username: Optional[str] = None + sap_id_type: Optional[str] = None + scim_id: Optional[str] = None + sid: Optional[str] = None + sub: Optional[str] = None + + +def parse_token(token: str) -> IASClaims: + """Parse an SAP IAS JWT token and return its claims. + + Decodes the token without signature verification. The token is not + validated against a JWKS endpoint — callers are responsible for + ensuring the token has already been verified by their framework or + middleware before using the extracted claims. + + Args: + token: A JWT string, optionally prefixed with ``"Bearer "`` or ``"bearer "``. + + Returns: + IASClaims with all present token claims populated. Absent claims + are None. + + Raises: + IASTokenError: If the token is malformed and cannot be decoded. + + Example: + ```python + from sap_cloud_sdk.ias import parse_token + + claims = parse_token(request.headers["Authorization"]) + print(claims.app_tid) # tenant ID + print(claims.scim_id) # SCIM-based user ID + print(claims.sub) # OIDC subject identifier + ``` + """ + raw = token.removeprefix("Bearer ").removeprefix("bearer ").strip() + + try: + payload: dict = jwt.decode( + raw, + options={"verify_signature": False}, + algorithms=["RS256", "ES256", "HS256"], + ) + except jwt.exceptions.DecodeError as e: + raise IASTokenError(f"Failed to decode IAS token: {e}") from e + + return IASClaims( + app_tid=payload.get("app_tid"), + at_hash=payload.get("at_hash"), + aud=payload.get("aud"), + auth_time=payload.get("auth_time"), + azp=payload.get("azp"), + email=payload.get("email"), + email_verified=payload.get("email_verified"), + exp=payload.get("exp"), + family_name=payload.get("family_name"), + given_name=payload.get("given_name"), + groups=payload.get("groups"), + ias_apis=payload.get("ias_apis"), + ias_iss=payload.get("ias_iss"), + iat=payload.get("iat"), + iss=payload.get("iss"), + jti=payload.get("jti"), + middle_name=payload.get("middle_name"), + name=payload.get("name"), + nonce=payload.get("nonce"), + preferred_username=payload.get("preferred_username"), + sap_id_type=payload.get("sap_id_type"), + scim_id=payload.get("scim_id"), + sid=payload.get("sid"), + sub=payload.get("sub"), + ) diff --git a/src/sap_cloud_sdk/ias/exceptions.py b/src/sap_cloud_sdk/ias/exceptions.py new file mode 100644 index 0000000..0ab7335 --- /dev/null +++ b/src/sap_cloud_sdk/ias/exceptions.py @@ -0,0 +1,5 @@ +"""Exceptions for the IAS module.""" + + +class IASTokenError(Exception): + """Raised when an IAS JWT token cannot be parsed.""" diff --git a/src/sap_cloud_sdk/ias/user-guide.md b/src/sap_cloud_sdk/ias/user-guide.md new file mode 100644 index 0000000..1570594 --- /dev/null +++ b/src/sap_cloud_sdk/ias/user-guide.md @@ -0,0 +1,73 @@ +# IAS User Guide + +This module provides utilities for working with SAP Identity Authentication Service (IAS). + +## Import + +```python +from sap_cloud_sdk.ias import parse_token, IASClaims, IASTokenError +``` + +--- + +## Token Parsing + +Use `parse_token` to decode an IAS JWT token into a typed dataclass. All standard IAS claims are mapped to named attributes. + +> **Note:** `parse_token` does **not** verify the token signature. Validate the token against the IAS JWKS endpoint in your framework or middleware before using the extracted claims for authorization decisions. + +```python +from sap_cloud_sdk.ias import parse_token + +claims = parse_token(request.headers["Authorization"]) # accepts "Bearer " or raw token + +print(claims.app_tid) # tenant ID (multitenant scenarios) +print(claims.scim_id) # SCIM-based user ID in SAP Cloud Identity Services +print(claims.sub) # OIDC subject identifier +print(claims.email) # user email (when email scope was requested) +``` + +### Claims Reference + +All fields on `IASClaims` are `Optional` — claims absent from the token are `None`. + +| Attribute | Claim | Description | +|----------------------|----------------------|-----------------------------------------------------------------------------------------------| +| `app_tid` | `app_tid` | SAP tenant of the application. Present in multitenant scenarios. | +| `at_hash` | `at_hash` | Hash of the access token, used to bind the ID token to an access token. | +| `aud` | `aud` | Audience — recipient(s) of the token. `str` or `List[str]`. | +| `auth_time` | `auth_time` | Time of user authentication (seconds since Unix epoch). | +| `azp` | `azp` | Authorized party — client ID to which the ID token was issued. | +| `email` | `email` | User email address. Requires `email` scope. | +| `email_verified` | `email_verified` | Whether the email address has been verified. Requires `email` scope. | +| `exp` | `exp` | Expiration time (seconds since Unix epoch). | +| `family_name` | `family_name` | Surname. Requires `profile` scope. | +| `given_name` | `given_name` | Given name. Requires `profile` scope. | +| `groups` | `groups` | Groups the user belongs to. Requires `groups` scope. | +| `ias_apis` | `ias_apis` | SAP API permission groups, or a fixed value when all APIs are consumed. `str` or `List[str]`. | +| `ias_iss` | `ias_iss` | SAP tenant identifier — stable even when using a custom domain. | +| `iat` | `iat` | Issued-at time (seconds since Unix epoch). | +| `iss` | `iss` | Issuer URL, e.g. `https://.accounts.ondemand.com`. | +| `jti` | `jti` | Unique JWT identifier, used to prevent replay attacks. Requires `profile` scope. | +| `middle_name` | `middle_name` | Middle name of the user. | +| `name` | `name` | Full display name. | +| `nonce` | `nonce` | Session nonce to mitigate replay attacks. | +| `preferred_username` | `preferred_username` | Human-readable username. | +| `sap_id_type` | `sap_id_type` | Token type: `"user"` for user credentials, `"app"` for application credentials. | +| `scim_id` | `scim_id` | User's SCIM ID in SAP Cloud Identity Services. | +| `sid` | `sid` | Session ID for tracking a user session across applications. | +| `sub` | `sub` | Subject — unique identifier for the user, scoped to the issuer. | + +> Claims are only present if the corresponding scope was requested during authentication. For example, `email` and `email_verified` require the `email` scope, and `given_name`/`family_name`/`jti` require the `profile` scope. + + +#### With Telemetry + +```python +from sap_cloud_sdk.ias import parse_token +from sap_cloud_sdk.core.telemetry import set_tenant_id, add_span_attribute + +claims = parse_token(token) +set_tenant_id(claims.app_tid or "") +add_span_attribute("enduser.id", claims.scim_id or claims.sub or "") +``` \ No newline at end of file diff --git a/tests/ias/__init__.py b/tests/ias/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ias/unit/__init__.py b/tests/ias/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ias/unit/test_token.py b/tests/ias/unit/test_token.py new file mode 100644 index 0000000..06036a7 --- /dev/null +++ b/tests/ias/unit/test_token.py @@ -0,0 +1,124 @@ +"""Unit tests for sap_cloud_sdk.ias token parsing.""" + +import pytest +import jwt as pyjwt + +from sap_cloud_sdk.ias import parse_token, IASClaims, IASTokenError + + +def _make_token(payload: dict) -> str: + """Encode a JWT with a dummy secret (signature verification is disabled).""" + return pyjwt.encode(payload, key="secret", algorithm="HS256") + + +class TestParseToken: + def test_all_claims_mapped(self): + payload = { + "app_tid": "tenant-abc", + "at_hash": "abc123hash", + "aud": "client-id", + "auth_time": 1700000000, + "azp": "authorized-party", + "email": "user@example.com", + "email_verified": True, + "exp": 1700003600, + "family_name": "Doe", + "given_name": "Jane", + "groups": ["admins", "users"], + "ias_apis": ["api-group-1"], + "ias_iss": "https://tenant.accounts.ondemand.com", + "iat": 1700000000, + "iss": "https://tenant.accounts.ondemand.com", + "jti": "unique-jwt-id", + "middle_name": "Marie", + "name": "Jane Marie Doe", + "nonce": "random-nonce", + "preferred_username": "jane.doe", + "sap_id_type": "user", + "scim_id": "scim-user-id-xyz", + "sid": "session-id-123", + "sub": "subject-unique-id", + } + claims = parse_token(_make_token(payload)) + + assert claims.app_tid == "tenant-abc" + assert claims.at_hash == "abc123hash" + assert claims.aud == "client-id" + assert claims.auth_time == 1700000000 + assert claims.azp == "authorized-party" + assert claims.email == "user@example.com" + assert claims.email_verified is True + assert claims.exp == 1700003600 + assert claims.family_name == "Doe" + assert claims.given_name == "Jane" + assert claims.groups == ["admins", "users"] + assert claims.ias_apis == ["api-group-1"] + assert claims.ias_iss == "https://tenant.accounts.ondemand.com" + assert claims.iat == 1700000000 + assert claims.iss == "https://tenant.accounts.ondemand.com" + assert claims.jti == "unique-jwt-id" + assert claims.middle_name == "Marie" + assert claims.name == "Jane Marie Doe" + assert claims.nonce == "random-nonce" + assert claims.preferred_username == "jane.doe" + assert claims.sap_id_type == "user" + assert claims.scim_id == "scim-user-id-xyz" + assert claims.sid == "session-id-123" + assert claims.sub == "subject-unique-id" + + def test_missing_optional_claims_are_none(self): + claims = parse_token(_make_token({"sub": "only-sub"})) + + assert claims.sub == "only-sub" + assert claims.app_tid is None + assert claims.email is None + assert claims.scim_id is None + assert claims.groups is None + + def test_empty_payload_returns_all_none(self): + claims = parse_token(_make_token({})) + assert isinstance(claims, IASClaims) + for field in IASClaims.__dataclass_fields__: + assert getattr(claims, field) is None + + def test_bearer_prefix_stripped(self): + token = _make_token({"sub": "user-1", "app_tid": "tid-1"}) + claims = parse_token(f"Bearer {token}") + + assert claims.sub == "user-1" + assert claims.app_tid == "tid-1" + + def test_bearer_prefix_lowercase_stripped(self): + token = _make_token({"sub": "user-1", "app_tid": "tid-1"}) + claims = parse_token(f"bearer {token}") + + assert claims.sub == "user-1" + assert claims.app_tid == "tid-1" + + def test_aud_as_list(self): + claims = parse_token(_make_token({"aud": ["client-a", "client-b"]})) + assert claims.aud == ["client-a", "client-b"] + + def test_aud_as_string(self): + claims = parse_token(_make_token({"aud": "single-client"})) + assert claims.aud == "single-client" + + def test_ias_apis_as_string(self): + claims = parse_token(_make_token({"ias_apis": "ALL"})) + assert claims.ias_apis == "ALL" + + def test_sap_id_type_app(self): + claims = parse_token(_make_token({"sap_id_type": "app"})) + assert claims.sap_id_type == "app" + + def test_malformed_token_raises_ias_token_error(self): + with pytest.raises(IASTokenError): + parse_token("not.a.valid.jwt") + + def test_empty_string_raises_ias_token_error(self): + with pytest.raises(IASTokenError): + parse_token("") + + def test_returns_ias_claims_instance(self): + claims = parse_token(_make_token({"sub": "x"})) + assert isinstance(claims, IASClaims) diff --git a/uv.lock b/uv.lock index 023df00..92d0146 100644 --- a/uv.lock +++ b/uv.lock @@ -2248,6 +2248,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + [[package]] name = "pytest" version = "8.4.2" @@ -2424,6 +2433,7 @@ dependencies = [ { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-processor-baggage" }, { name = "pydantic" }, + { name = "pyjwt" }, { name = "requests" }, { name = "requests-oauthlib" }, { name = "setuptools" }, @@ -2449,6 +2459,7 @@ requires-dist = [ { name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.38.0" }, { name = "opentelemetry-processor-baggage", specifier = "~=0.61b0" }, { name = "pydantic", specifier = "~=2.12.3" }, + { name = "pyjwt", specifier = "~=2.10.1" }, { name = "requests", specifier = "~=2.31.0" }, { name = "requests-oauthlib", specifier = "~=2.0.0" }, { name = "setuptools", specifier = "~=80.9.0" }, From 4226167fbdfbfd2b3106ff748effac2f2e6f89f3 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Thu, 2 Apr 2026 12:07:11 -0300 Subject: [PATCH 2/7] chore: docs --- src/sap_cloud_sdk/ias/_token.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sap_cloud_sdk/ias/_token.py b/src/sap_cloud_sdk/ias/_token.py index 89fb16b..29e70b8 100644 --- a/src/sap_cloud_sdk/ias/_token.py +++ b/src/sap_cloud_sdk/ias/_token.py @@ -19,8 +19,6 @@ class IASClaims: """Typed representation of SAP IAS JWT token claims. All fields are optional — a claim absent from the token is None. - No interpretation of which field represents a "global user ID" is - made by the SDK; use whichever claim fits your use case. Attributes: app_tid: SAP claim identifying the tenant of the application. From 4c64990678f0af6a6007f3d98af6800a0d8849c8 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Thu, 2 Apr 2026 13:58:50 -0300 Subject: [PATCH 3/7] feat: user_uuid and custom attributes --- src/sap_cloud_sdk/ias/_token.py | 32 ++++++++++++++++++++------- src/sap_cloud_sdk/ias/user-guide.md | 11 +++++++++- tests/ias/unit/test_token.py | 34 +++++++++++++++++++++++++++-- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/src/sap_cloud_sdk/ias/_token.py b/src/sap_cloud_sdk/ias/_token.py index 29e70b8..33ac5cf 100644 --- a/src/sap_cloud_sdk/ias/_token.py +++ b/src/sap_cloud_sdk/ias/_token.py @@ -6,19 +6,28 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import List, Optional, Union +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Union import jwt from sap_cloud_sdk.ias.exceptions import IASTokenError +_KNOWN_CLAIMS = { + "app_tid", "at_hash", "aud", "auth_time", "azp", "email", + "email_verified", "exp", "family_name", "given_name", "groups", + "ias_apis", "ias_iss", "iat", "iss", "jti", "middle_name", "name", + "nonce", "preferred_username", "sap_id_type", "scim_id", "sid", + "sub", "user_uuid", +} + @dataclass class IASClaims: """Typed representation of SAP IAS JWT token claims. - All fields are optional — a claim absent from the token is None. + All standard fields are optional — a claim absent from the token is None. + Any claims not in the standard set are collected in ``custom_attributes``. Attributes: app_tid: SAP claim identifying the tenant of the application. @@ -62,6 +71,9 @@ class IASClaims: sid: Session ID used to track a user session across applications and logout scenarios. sub: Subject — unique identifier for the user, scoped to the issuer. + user_uuid: SAP claim identifying the global user ID. + custom_attributes: Any claims present in the token that are not part + of the standard IAS claim set. """ app_tid: Optional[str] = None @@ -88,6 +100,8 @@ class IASClaims: scim_id: Optional[str] = None sid: Optional[str] = None sub: Optional[str] = None + user_uuid: Optional[str] = None + custom_attributes: Dict[str, Any] = field(default_factory=dict) def parse_token(token: str) -> IASClaims: @@ -102,8 +116,9 @@ def parse_token(token: str) -> IASClaims: token: A JWT string, optionally prefixed with ``"Bearer "`` or ``"bearer "``. Returns: - IASClaims with all present token claims populated. Absent claims - are None. + IASClaims with all present token claims populated. Absent standard + claims are None. Unrecognised claims are collected in + ``custom_attributes``. Raises: IASTokenError: If the token is malformed and cannot be decoded. @@ -113,9 +128,8 @@ def parse_token(token: str) -> IASClaims: from sap_cloud_sdk.ias import parse_token claims = parse_token(request.headers["Authorization"]) - print(claims.app_tid) # tenant ID - print(claims.scim_id) # SCIM-based user ID - print(claims.sub) # OIDC subject identifier + print(claims.user_uuid) # global user ID + print(claims.custom_attributes) # any non-standard claims ``` """ raw = token.removeprefix("Bearer ").removeprefix("bearer ").strip() @@ -154,4 +168,6 @@ def parse_token(token: str) -> IASClaims: scim_id=payload.get("scim_id"), sid=payload.get("sid"), sub=payload.get("sub"), + user_uuid=payload.get("user_uuid"), + custom_attributes={k: v for k, v in payload.items() if k not in _KNOWN_CLAIMS}, ) diff --git a/src/sap_cloud_sdk/ias/user-guide.md b/src/sap_cloud_sdk/ias/user-guide.md index 1570594..06a5a8d 100644 --- a/src/sap_cloud_sdk/ias/user-guide.md +++ b/src/sap_cloud_sdk/ias/user-guide.md @@ -57,8 +57,17 @@ All fields on `IASClaims` are `Optional` — claims absent from the token are `N | `scim_id` | `scim_id` | User's SCIM ID in SAP Cloud Identity Services. | | `sid` | `sid` | Session ID for tracking a user session across applications. | | `sub` | `sub` | Subject — unique identifier for the user, scoped to the issuer. | +| `user_uuid` | `user_uuid` | SAP claim identifying the global user ID. | +| `custom_attributes` | *(any)* | Claims not in the standard IAS set. Always a `dict`, empty if no custom claims are present. | -> Claims are only present if the corresponding scope was requested during authentication. For example, `email` and `email_verified` require the `email` scope, and `given_name`/`family_name`/`jti` require the `profile` scope. +### Custom Attributes + +Any claim not in the standard IAS set lands in `custom_attributes` as a plain dict, so nothing is silently dropped: + +```python +claims = parse_token(token) +print(claims.custom_attributes) # {"my_app_claim": "value", ...} +``` #### With Telemetry diff --git a/tests/ias/unit/test_token.py b/tests/ias/unit/test_token.py index 06036a7..ec1f189 100644 --- a/tests/ias/unit/test_token.py +++ b/tests/ias/unit/test_token.py @@ -38,6 +38,7 @@ def test_all_claims_mapped(self): "scim_id": "scim-user-id-xyz", "sid": "session-id-123", "sub": "subject-unique-id", + "user_uuid": "global-user-id-xyz", } claims = parse_token(_make_token(payload)) @@ -65,6 +66,8 @@ def test_all_claims_mapped(self): assert claims.scim_id == "scim-user-id-xyz" assert claims.sid == "session-id-123" assert claims.sub == "subject-unique-id" + assert claims.user_uuid == "global-user-id-xyz" + assert claims.custom_attributes == {} def test_missing_optional_claims_are_none(self): claims = parse_token(_make_token({"sub": "only-sub"})) @@ -74,12 +77,17 @@ def test_missing_optional_claims_are_none(self): assert claims.email is None assert claims.scim_id is None assert claims.groups is None + assert claims.user_uuid is None + assert claims.custom_attributes == {} def test_empty_payload_returns_all_none(self): claims = parse_token(_make_token({})) assert isinstance(claims, IASClaims) - for field in IASClaims.__dataclass_fields__: - assert getattr(claims, field) is None + for f in IASClaims.__dataclass_fields__: + if f == "custom_attributes": + assert getattr(claims, f) == {} + else: + assert getattr(claims, f) is None def test_bearer_prefix_stripped(self): token = _make_token({"sub": "user-1", "app_tid": "tid-1"}) @@ -122,3 +130,25 @@ def test_empty_string_raises_ias_token_error(self): def test_returns_ias_claims_instance(self): claims = parse_token(_make_token({"sub": "x"})) assert isinstance(claims, IASClaims) + + def test_user_uuid_mapped(self): + claims = parse_token(_make_token({"user_uuid": "global-user-id"})) + assert claims.user_uuid == "global-user-id" + + def test_custom_attributes_captures_unknown_claims(self): + payload = {"sub": "x", "my_app_claim": "value", "another_custom": 42} + claims = parse_token(_make_token(payload)) + + assert claims.custom_attributes == {"my_app_claim": "value", "another_custom": 42} + + def test_custom_attributes_empty_when_no_unknown_claims(self): + claims = parse_token(_make_token({"sub": "x", "email": "a@b.com"})) + assert claims.custom_attributes == {} + + def test_known_claims_not_duplicated_in_custom_attributes(self): + payload = {"sub": "x", "user_uuid": "uid", "unknown_claim": "yes"} + claims = parse_token(_make_token(payload)) + + assert "sub" not in claims.custom_attributes + assert "user_uuid" not in claims.custom_attributes + assert claims.custom_attributes == {"unknown_claim": "yes"} From 69f56f81358758b74186b816112cfa52af12f674 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Thu, 2 Apr 2026 14:05:34 -0300 Subject: [PATCH 4/7] chore: linting --- src/sap_cloud_sdk/ias/_token.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/sap_cloud_sdk/ias/_token.py b/src/sap_cloud_sdk/ias/_token.py index 33ac5cf..8cfe1c9 100644 --- a/src/sap_cloud_sdk/ias/_token.py +++ b/src/sap_cloud_sdk/ias/_token.py @@ -14,11 +14,31 @@ from sap_cloud_sdk.ias.exceptions import IASTokenError _KNOWN_CLAIMS = { - "app_tid", "at_hash", "aud", "auth_time", "azp", "email", - "email_verified", "exp", "family_name", "given_name", "groups", - "ias_apis", "ias_iss", "iat", "iss", "jti", "middle_name", "name", - "nonce", "preferred_username", "sap_id_type", "scim_id", "sid", - "sub", "user_uuid", + "app_tid", + "at_hash", + "aud", + "auth_time", + "azp", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "groups", + "ias_apis", + "ias_iss", + "iat", + "iss", + "jti", + "middle_name", + "name", + "nonce", + "preferred_username", + "sap_id_type", + "scim_id", + "sid", + "sub", + "user_uuid", } From 7f7a5c00f7c150e81d7699c77b5cb70d646b3310 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Thu, 2 Apr 2026 16:40:04 -0300 Subject: [PATCH 5/7] chore: make claims enums --- src/sap_cloud_sdk/ias/_claims.py | 34 ++++++++++++++ src/sap_cloud_sdk/ias/_token.py | 81 +++++++++++--------------------- 2 files changed, 61 insertions(+), 54 deletions(-) create mode 100644 src/sap_cloud_sdk/ias/_claims.py diff --git a/src/sap_cloud_sdk/ias/_claims.py b/src/sap_cloud_sdk/ias/_claims.py new file mode 100644 index 0000000..16978c2 --- /dev/null +++ b/src/sap_cloud_sdk/ias/_claims.py @@ -0,0 +1,34 @@ +"""Internal IAS JWT claim name definitions.""" + +from enum import Enum + + +class _IASClaim(str, Enum): + APP_TID = "app_tid" + AT_HASH = "at_hash" + AUD = "aud" + AUTH_TIME = "auth_time" + AZP = "azp" + EMAIL = "email" + EMAIL_VERIFIED = "email_verified" + EXP = "exp" + FAMILY_NAME = "family_name" + GIVEN_NAME = "given_name" + GROUPS = "groups" + IAS_APIS = "ias_apis" + IAS_ISS = "ias_iss" + IAT = "iat" + ISS = "iss" + JTI = "jti" + MIDDLE_NAME = "middle_name" + NAME = "name" + NONCE = "nonce" + PREFERRED_USERNAME = "preferred_username" + SAP_ID_TYPE = "sap_id_type" + SCIM_ID = "scim_id" + SID = "sid" + SUB = "sub" + USER_UUID = "user_uuid" + + +_KNOWN_CLAIM_VALUES: frozenset[str] = frozenset(c.value for c in _IASClaim) diff --git a/src/sap_cloud_sdk/ias/_token.py b/src/sap_cloud_sdk/ias/_token.py index 8cfe1c9..537c0c5 100644 --- a/src/sap_cloud_sdk/ias/_token.py +++ b/src/sap_cloud_sdk/ias/_token.py @@ -11,36 +11,9 @@ import jwt +from sap_cloud_sdk.ias._claims import _IASClaim, _KNOWN_CLAIM_VALUES from sap_cloud_sdk.ias.exceptions import IASTokenError -_KNOWN_CLAIMS = { - "app_tid", - "at_hash", - "aud", - "auth_time", - "azp", - "email", - "email_verified", - "exp", - "family_name", - "given_name", - "groups", - "ias_apis", - "ias_iss", - "iat", - "iss", - "jti", - "middle_name", - "name", - "nonce", - "preferred_username", - "sap_id_type", - "scim_id", - "sid", - "sub", - "user_uuid", -} - @dataclass class IASClaims: @@ -164,30 +137,30 @@ def parse_token(token: str) -> IASClaims: raise IASTokenError(f"Failed to decode IAS token: {e}") from e return IASClaims( - app_tid=payload.get("app_tid"), - at_hash=payload.get("at_hash"), - aud=payload.get("aud"), - auth_time=payload.get("auth_time"), - azp=payload.get("azp"), - email=payload.get("email"), - email_verified=payload.get("email_verified"), - exp=payload.get("exp"), - family_name=payload.get("family_name"), - given_name=payload.get("given_name"), - groups=payload.get("groups"), - ias_apis=payload.get("ias_apis"), - ias_iss=payload.get("ias_iss"), - iat=payload.get("iat"), - iss=payload.get("iss"), - jti=payload.get("jti"), - middle_name=payload.get("middle_name"), - name=payload.get("name"), - nonce=payload.get("nonce"), - preferred_username=payload.get("preferred_username"), - sap_id_type=payload.get("sap_id_type"), - scim_id=payload.get("scim_id"), - sid=payload.get("sid"), - sub=payload.get("sub"), - user_uuid=payload.get("user_uuid"), - custom_attributes={k: v for k, v in payload.items() if k not in _KNOWN_CLAIMS}, + app_tid=payload.get(_IASClaim.APP_TID), + at_hash=payload.get(_IASClaim.AT_HASH), + aud=payload.get(_IASClaim.AUD), + auth_time=payload.get(_IASClaim.AUTH_TIME), + azp=payload.get(_IASClaim.AZP), + email=payload.get(_IASClaim.EMAIL), + email_verified=payload.get(_IASClaim.EMAIL_VERIFIED), + exp=payload.get(_IASClaim.EXP), + family_name=payload.get(_IASClaim.FAMILY_NAME), + given_name=payload.get(_IASClaim.GIVEN_NAME), + groups=payload.get(_IASClaim.GROUPS), + ias_apis=payload.get(_IASClaim.IAS_APIS), + ias_iss=payload.get(_IASClaim.IAS_ISS), + iat=payload.get(_IASClaim.IAT), + iss=payload.get(_IASClaim.ISS), + jti=payload.get(_IASClaim.JTI), + middle_name=payload.get(_IASClaim.MIDDLE_NAME), + name=payload.get(_IASClaim.NAME), + nonce=payload.get(_IASClaim.NONCE), + preferred_username=payload.get(_IASClaim.PREFERRED_USERNAME), + sap_id_type=payload.get(_IASClaim.SAP_ID_TYPE), + scim_id=payload.get(_IASClaim.SCIM_ID), + sid=payload.get(_IASClaim.SID), + sub=payload.get(_IASClaim.SUB), + user_uuid=payload.get(_IASClaim.USER_UUID), + custom_attributes={k: v for k, v in payload.items() if k not in _KNOWN_CLAIM_VALUES}, ) From 5af3dd6fe2aa416b4acc29d7a2e99cf97c414a70 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Thu, 2 Apr 2026 20:19:30 -0300 Subject: [PATCH 6/7] chore: linting --- src/sap_cloud_sdk/ias/_token.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/ias/_token.py b/src/sap_cloud_sdk/ias/_token.py index 537c0c5..debb460 100644 --- a/src/sap_cloud_sdk/ias/_token.py +++ b/src/sap_cloud_sdk/ias/_token.py @@ -162,5 +162,7 @@ def parse_token(token: str) -> IASClaims: sid=payload.get(_IASClaim.SID), sub=payload.get(_IASClaim.SUB), user_uuid=payload.get(_IASClaim.USER_UUID), - custom_attributes={k: v for k, v in payload.items() if k not in _KNOWN_CLAIM_VALUES}, + custom_attributes={ + k: v for k, v in payload.items() if k not in _KNOWN_CLAIM_VALUES + }, ) From 9ccfa2bb6dc13867e996b07668cfb2a750bd2dae Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Fri, 3 Apr 2026 13:18:23 -0300 Subject: [PATCH 7/7] chore: version bump --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4401b99..808bbe7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.4.0" +version = "0.5.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/uv.lock b/uv.lock index 92d0146..e2252cf 100644 --- a/uv.lock +++ b/uv.lock @@ -2424,7 +2424,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "hatchling" },