Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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]
Expand Down
22 changes: 22 additions & 0 deletions src/sap_cloud_sdk/ias/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
34 changes: 34 additions & 0 deletions src/sap_cloud_sdk/ias/_claims.py
Original file line number Diff line number Diff line change
@@ -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)
168 changes: 168 additions & 0 deletions src/sap_cloud_sdk/ias/_token.py
Original file line number Diff line number Diff line change
@@ -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://<tenant>.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
},
)
5 changes: 5 additions & 0 deletions src/sap_cloud_sdk/ias/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Exceptions for the IAS module."""


class IASTokenError(Exception):
"""Raised when an IAS JWT token cannot be parsed."""
82 changes: 82 additions & 0 deletions src/sap_cloud_sdk/ias/user-guide.md
Original file line number Diff line number Diff line change
@@ -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 <token>" 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://<tenant>.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 "")
```
Empty file added tests/ias/__init__.py
Empty file.
Empty file added tests/ias/unit/__init__.py
Empty file.
Loading
Loading