Skip to content

Commit 7ad7d31

Browse files
feat: ias module with token parsing
1 parent 7b66f64 commit 7ad7d31

File tree

10 files changed

+480
-3
lines changed

10 files changed

+480
-3
lines changed

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sap-cloud-sdk"
3-
version = "0.4.0"
3+
version = "0.5.0"
44
description = "SAP Cloud SDK for Python"
55
readme = "README.md"
66
license = "Apache-2.0"
@@ -18,7 +18,8 @@ dependencies = [
1818
"opentelemetry-exporter-otlp-proto-grpc~=1.38.0",
1919
"opentelemetry-exporter-otlp-proto-http~=1.38.0",
2020
"opentelemetry-processor-baggage~=0.61b0",
21-
"traceloop-sdk~=0.52.0"
21+
"traceloop-sdk~=0.52.0",
22+
"PyJWT~=2.10.1",
2223
]
2324

2425
[build-system]

src/sap_cloud_sdk/ias/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""SAP Cloud SDK for Python - IAS module
2+
3+
Utilities for parsing SAP Identity Authentication Service (IAS) JWT tokens.
4+
5+
Usage:
6+
from sap_cloud_sdk.ias import parse_token, IASClaims
7+
8+
claims = parse_token(request.headers["Authorization"])
9+
print(claims.app_tid) # tenant ID (multitenant scenarios)
10+
print(claims.scim_id) # SCIM-based user ID
11+
print(claims.sub) # OIDC subject identifier
12+
print(claims.email) # user email (when email scope requested)
13+
"""
14+
15+
from sap_cloud_sdk.ias._token import IASClaims, parse_token
16+
from sap_cloud_sdk.ias.exceptions import IASTokenError
17+
18+
__all__ = [
19+
"IASClaims",
20+
"parse_token",
21+
"IASTokenError",
22+
]

src/sap_cloud_sdk/ias/_claims.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Internal IAS JWT claim name definitions."""
2+
3+
from enum import Enum
4+
5+
6+
class _IASClaim(str, Enum):
7+
APP_TID = "app_tid"
8+
AT_HASH = "at_hash"
9+
AUD = "aud"
10+
AUTH_TIME = "auth_time"
11+
AZP = "azp"
12+
EMAIL = "email"
13+
EMAIL_VERIFIED = "email_verified"
14+
EXP = "exp"
15+
FAMILY_NAME = "family_name"
16+
GIVEN_NAME = "given_name"
17+
GROUPS = "groups"
18+
IAS_APIS = "ias_apis"
19+
IAS_ISS = "ias_iss"
20+
IAT = "iat"
21+
ISS = "iss"
22+
JTI = "jti"
23+
MIDDLE_NAME = "middle_name"
24+
NAME = "name"
25+
NONCE = "nonce"
26+
PREFERRED_USERNAME = "preferred_username"
27+
SAP_ID_TYPE = "sap_id_type"
28+
SCIM_ID = "scim_id"
29+
SID = "sid"
30+
SUB = "sub"
31+
USER_UUID = "user_uuid"
32+
33+
34+
_KNOWN_CLAIM_VALUES: frozenset[str] = frozenset(c.value for c in _IASClaim)

src/sap_cloud_sdk/ias/_token.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""IAS JWT token parsing.
2+
3+
Decodes SAP IAS JWT tokens without signature verification and maps
4+
all standard IAS claims to a typed dataclass.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from dataclasses import dataclass, field
10+
from typing import Any, Dict, List, Optional, Union
11+
12+
import jwt
13+
14+
from sap_cloud_sdk.ias._claims import _IASClaim, _KNOWN_CLAIM_VALUES
15+
from sap_cloud_sdk.ias.exceptions import IASTokenError
16+
17+
18+
@dataclass
19+
class IASClaims:
20+
"""Typed representation of SAP IAS JWT token claims.
21+
22+
All standard fields are optional — a claim absent from the token is None.
23+
Any claims not in the standard set are collected in ``custom_attributes``.
24+
25+
Attributes:
26+
app_tid: SAP claim identifying the tenant of the application.
27+
Used in multitenant scenarios (e.g. subscribed BTP applications).
28+
at_hash: Hash of the access token. Can be used to bind the ID token
29+
to an access token and prevent token substitution attacks.
30+
aud: Audience — recipient(s) of the token. Can be a string or list
31+
of client IDs.
32+
auth_time: Time when the user authenticated (seconds since Unix epoch).
33+
azp: Authorized party — client ID to which the ID token was issued.
34+
email: Email address of the user. Included when the email scope is
35+
requested.
36+
email_verified: Whether the email address has been verified.
37+
exp: Expiration time after which the token must not be accepted
38+
(seconds since Unix epoch).
39+
family_name: Surname (last name) of the user. Included when the
40+
profile scope is requested.
41+
given_name: Given name (first name) of the user. Included when the
42+
profile scope is requested.
43+
groups: Groups the user belongs to, associated with authorizations.
44+
Included when the groups scope is requested.
45+
ias_apis: SAP claim listing API permission groups or a fixed value
46+
when all APIs of an application are consumed.
47+
ias_iss: SAP claim identifying the SAP tenant even when the token was
48+
issued from a custom domain in a non-SAP domain.
49+
iat: Time when the token was issued (seconds since Unix epoch).
50+
iss: Issuer of the token, typically a URL such as
51+
https://<tenant>.accounts.ondemand.com.
52+
jti: Unique identifier for the JWT, used to prevent replay attacks.
53+
Included when the profile scope is requested.
54+
middle_name: Middle name of the user.
55+
name: Full display name of the user.
56+
nonce: String associated with the client session to mitigate replay
57+
attacks.
58+
preferred_username: Human-readable display name / username of the user.
59+
sap_id_type: SAP claim identifying the type of token.
60+
``"app"`` for application credentials, ``"user"`` for user
61+
credentials.
62+
scim_id: SAP claim identifying the user by their SCIM ID in SAP Cloud
63+
Identity Services.
64+
sid: Session ID used to track a user session across applications and
65+
logout scenarios.
66+
sub: Subject — unique identifier for the user, scoped to the issuer.
67+
user_uuid: SAP claim identifying the global user ID.
68+
custom_attributes: Any claims present in the token that are not part
69+
of the standard IAS claim set.
70+
"""
71+
72+
app_tid: Optional[str] = None
73+
at_hash: Optional[str] = None
74+
aud: Optional[Union[str, List[str]]] = None
75+
auth_time: Optional[int] = None
76+
azp: Optional[str] = None
77+
email: Optional[str] = None
78+
email_verified: Optional[bool] = None
79+
exp: Optional[int] = None
80+
family_name: Optional[str] = None
81+
given_name: Optional[str] = None
82+
groups: Optional[List[str]] = None
83+
ias_apis: Optional[Union[str, List[str]]] = None
84+
ias_iss: Optional[str] = None
85+
iat: Optional[int] = None
86+
iss: Optional[str] = None
87+
jti: Optional[str] = None
88+
middle_name: Optional[str] = None
89+
name: Optional[str] = None
90+
nonce: Optional[str] = None
91+
preferred_username: Optional[str] = None
92+
sap_id_type: Optional[str] = None
93+
scim_id: Optional[str] = None
94+
sid: Optional[str] = None
95+
sub: Optional[str] = None
96+
user_uuid: Optional[str] = None
97+
custom_attributes: Dict[str, Any] = field(default_factory=dict)
98+
99+
100+
def parse_token(token: str) -> IASClaims:
101+
"""Parse an SAP IAS JWT token and return its claims.
102+
103+
Decodes the token without signature verification. The token is not
104+
validated against a JWKS endpoint — callers are responsible for
105+
ensuring the token has already been verified by their framework or
106+
middleware before using the extracted claims.
107+
108+
Args:
109+
token: A JWT string, optionally prefixed with ``"Bearer "`` or ``"bearer "``.
110+
111+
Returns:
112+
IASClaims with all present token claims populated. Absent standard
113+
claims are None. Unrecognised claims are collected in
114+
``custom_attributes``.
115+
116+
Raises:
117+
IASTokenError: If the token is malformed and cannot be decoded.
118+
119+
Example:
120+
```python
121+
from sap_cloud_sdk.ias import parse_token
122+
123+
claims = parse_token(request.headers["Authorization"])
124+
print(claims.user_uuid) # global user ID
125+
print(claims.custom_attributes) # any non-standard claims
126+
```
127+
"""
128+
raw = token.removeprefix("Bearer ").removeprefix("bearer ").strip()
129+
130+
try:
131+
payload: dict = jwt.decode(
132+
raw,
133+
options={"verify_signature": False},
134+
algorithms=["RS256", "ES256", "HS256"],
135+
)
136+
except jwt.exceptions.DecodeError as e:
137+
raise IASTokenError(f"Failed to decode IAS token: {e}") from e
138+
139+
return IASClaims(
140+
app_tid=payload.get(_IASClaim.APP_TID),
141+
at_hash=payload.get(_IASClaim.AT_HASH),
142+
aud=payload.get(_IASClaim.AUD),
143+
auth_time=payload.get(_IASClaim.AUTH_TIME),
144+
azp=payload.get(_IASClaim.AZP),
145+
email=payload.get(_IASClaim.EMAIL),
146+
email_verified=payload.get(_IASClaim.EMAIL_VERIFIED),
147+
exp=payload.get(_IASClaim.EXP),
148+
family_name=payload.get(_IASClaim.FAMILY_NAME),
149+
given_name=payload.get(_IASClaim.GIVEN_NAME),
150+
groups=payload.get(_IASClaim.GROUPS),
151+
ias_apis=payload.get(_IASClaim.IAS_APIS),
152+
ias_iss=payload.get(_IASClaim.IAS_ISS),
153+
iat=payload.get(_IASClaim.IAT),
154+
iss=payload.get(_IASClaim.ISS),
155+
jti=payload.get(_IASClaim.JTI),
156+
middle_name=payload.get(_IASClaim.MIDDLE_NAME),
157+
name=payload.get(_IASClaim.NAME),
158+
nonce=payload.get(_IASClaim.NONCE),
159+
preferred_username=payload.get(_IASClaim.PREFERRED_USERNAME),
160+
sap_id_type=payload.get(_IASClaim.SAP_ID_TYPE),
161+
scim_id=payload.get(_IASClaim.SCIM_ID),
162+
sid=payload.get(_IASClaim.SID),
163+
sub=payload.get(_IASClaim.SUB),
164+
user_uuid=payload.get(_IASClaim.USER_UUID),
165+
custom_attributes={
166+
k: v for k, v in payload.items() if k not in _KNOWN_CLAIM_VALUES
167+
},
168+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Exceptions for the IAS module."""
2+
3+
4+
class IASTokenError(Exception):
5+
"""Raised when an IAS JWT token cannot be parsed."""
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# IAS User Guide
2+
3+
This module provides utilities for working with SAP Identity Authentication Service (IAS).
4+
5+
## Import
6+
7+
```python
8+
from sap_cloud_sdk.ias import parse_token, IASClaims, IASTokenError
9+
```
10+
11+
---
12+
13+
## Token Parsing
14+
15+
Use `parse_token` to decode an IAS JWT token into a typed dataclass. All standard IAS claims are mapped to named attributes.
16+
17+
> **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.
18+
19+
```python
20+
from sap_cloud_sdk.ias import parse_token
21+
22+
claims = parse_token(request.headers["Authorization"]) # accepts "Bearer <token>" or raw token
23+
24+
print(claims.app_tid) # tenant ID (multitenant scenarios)
25+
print(claims.scim_id) # SCIM-based user ID in SAP Cloud Identity Services
26+
print(claims.sub) # OIDC subject identifier
27+
print(claims.email) # user email (when email scope was requested)
28+
```
29+
30+
### Claims Reference
31+
32+
All fields on `IASClaims` are `Optional` — claims absent from the token are `None`.
33+
34+
| Attribute | Claim | Description |
35+
|----------------------|----------------------|-----------------------------------------------------------------------------------------------|
36+
| `app_tid` | `app_tid` | SAP tenant of the application. Present in multitenant scenarios. |
37+
| `at_hash` | `at_hash` | Hash of the access token, used to bind the ID token to an access token. |
38+
| `aud` | `aud` | Audience — recipient(s) of the token. `str` or `List[str]`. |
39+
| `auth_time` | `auth_time` | Time of user authentication (seconds since Unix epoch). |
40+
| `azp` | `azp` | Authorized party — client ID to which the ID token was issued. |
41+
| `email` | `email` | User email address. Requires `email` scope. |
42+
| `email_verified` | `email_verified` | Whether the email address has been verified. Requires `email` scope. |
43+
| `exp` | `exp` | Expiration time (seconds since Unix epoch). |
44+
| `family_name` | `family_name` | Surname. Requires `profile` scope. |
45+
| `given_name` | `given_name` | Given name. Requires `profile` scope. |
46+
| `groups` | `groups` | Groups the user belongs to. Requires `groups` scope. |
47+
| `ias_apis` | `ias_apis` | SAP API permission groups, or a fixed value when all APIs are consumed. `str` or `List[str]`. |
48+
| `ias_iss` | `ias_iss` | SAP tenant identifier — stable even when using a custom domain. |
49+
| `iat` | `iat` | Issued-at time (seconds since Unix epoch). |
50+
| `iss` | `iss` | Issuer URL, e.g. `https://<tenant>.accounts.ondemand.com`. |
51+
| `jti` | `jti` | Unique JWT identifier, used to prevent replay attacks. Requires `profile` scope. |
52+
| `middle_name` | `middle_name` | Middle name of the user. |
53+
| `name` | `name` | Full display name. |
54+
| `nonce` | `nonce` | Session nonce to mitigate replay attacks. |
55+
| `preferred_username` | `preferred_username` | Human-readable username. |
56+
| `sap_id_type` | `sap_id_type` | Token type: `"user"` for user credentials, `"app"` for application credentials. |
57+
| `scim_id` | `scim_id` | User's SCIM ID in SAP Cloud Identity Services. |
58+
| `sid` | `sid` | Session ID for tracking a user session across applications. |
59+
| `sub` | `sub` | Subject — unique identifier for the user, scoped to the issuer. |
60+
| `user_uuid` | `user_uuid` | SAP claim identifying the global user ID. |
61+
| `custom_attributes` | *(any)* | Claims not in the standard IAS set. Always a `dict`, empty if no custom claims are present. |
62+
63+
### Custom Attributes
64+
65+
Any claim not in the standard IAS set lands in `custom_attributes` as a plain dict, so nothing is silently dropped:
66+
67+
```python
68+
claims = parse_token(token)
69+
print(claims.custom_attributes) # {"my_app_claim": "value", ...}
70+
```
71+
72+
73+
#### With Telemetry
74+
75+
```python
76+
from sap_cloud_sdk.ias import parse_token
77+
from sap_cloud_sdk.core.telemetry import set_tenant_id, add_span_attribute
78+
79+
claims = parse_token(token)
80+
set_tenant_id(claims.app_tid or "")
81+
add_span_attribute("enduser.id", claims.scim_id or claims.sub or "")
82+
```

tests/ias/__init__.py

Whitespace-only changes.

tests/ias/unit/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)