|
| 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 | + ) |
0 commit comments