diff --git a/pyproject.toml b/pyproject.toml index 9334cbf..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" @@ -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/_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 new file mode 100644 index 0000000..debb460 --- /dev/null +++ b/src/sap_cloud_sdk/ias/_token.py @@ -0,0 +1,168 @@ +"""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, field +from typing import Any, Dict, List, Optional, Union + +import jwt + +from sap_cloud_sdk.ias._claims import _IASClaim, _KNOWN_CLAIM_VALUES +from sap_cloud_sdk.ias.exceptions import IASTokenError + + +@dataclass +class IASClaims: + """Typed representation of SAP IAS JWT token claims. + + 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. + 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. + 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 + 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 + user_uuid: Optional[str] = None + custom_attributes: Dict[str, Any] = field(default_factory=dict) + + +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 standard + claims are None. Unrecognised claims are collected in + ``custom_attributes``. + + 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.user_uuid) # global user ID + print(claims.custom_attributes) # any non-standard claims + ``` + """ + 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(_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 + }, + ) 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..06a5a8d --- /dev/null +++ b/src/sap_cloud_sdk/ias/user-guide.md @@ -0,0 +1,82 @@ +# 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. | +| `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. | + +### 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 + +```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..ec1f189 --- /dev/null +++ b/tests/ias/unit/test_token.py @@ -0,0 +1,154 @@ +"""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", + "user_uuid": "global-user-id-xyz", + } + 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" + 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"})) + + 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 + 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 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"}) + 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) + + 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"} diff --git a/uv.lock b/uv.lock index 023df00..e2252cf 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" @@ -2415,7 +2424,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "hatchling" }, @@ -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" },