From 6babe4c888931e4f93e1e812ae66563da48ae2a6 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Fri, 2 Oct 2026 14:19:36 -0300 Subject: [PATCH 1/2] fix(ias): require verified provenance for IAS telemetry identity attributes (HASI2026203-278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StarletteIASTelemetryMiddleware previously called parse_token() (unverified JWT decode) and promoted sap_gtid/user_uuid to sap.tenancy.tenant_id/user.id span attributes. A remote sender could submit an attacker-signed JWT naming a victim tenant/user to pollute telemetry attribution. Fix: introduce VerifiedIASClaims (frozen dataclass) and TokenVerifier (Callable type alias) as provenance markers in the IAS module. The middleware now requires an explicit token_verifier parameter — if absent it fails closed (no identity attributes stamped, one-time WARNING logged). If the verifier raises for any reason, identity attributes are silently omitted. x-sap-origin (trigger type, not JWT identity) is stamped independently of token verification. parse_token() and AuditClient are unchanged. No new runtime dependencies. --- .../telemetry/middleware/starlette_a2a.py | 104 +++++-- .../core/telemetry/user-guide.md | 26 +- src/sap_cloud_sdk/ias/__init__.py | 6 +- src/sap_cloud_sdk/ias/_token.py | 26 +- src/sap_cloud_sdk/ias/user-guide.md | 99 ++++++- .../middleware/test_starlette_a2a.py | 272 +++++++++++++----- tests/ias/unit/test_token.py | 30 +- uv.lock | 38 ++- 8 files changed, 486 insertions(+), 115 deletions(-) diff --git a/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py b/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py index 1b34c733..f45f09e7 100644 --- a/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py +++ b/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py @@ -2,7 +2,7 @@ import logging from contextvars import ContextVar -from typing import Any, Dict +from typing import Any, Dict, Optional from sap_cloud_sdk.core.telemetry.constants import ( ATTR_SAP_TRIGGER_TYPE, @@ -10,7 +10,7 @@ ATTR_USER_ID, ) from sap_cloud_sdk.core.telemetry.middleware.base import TelemetryMiddleware -from sap_cloud_sdk.ias import parse_token +from sap_cloud_sdk.ias import TokenVerifier, VerifiedIASClaims # noqa: F401 try: from starlette.middleware.base import BaseHTTPMiddleware @@ -26,12 +26,18 @@ class _IASMiddleware(BaseHTTPMiddleware): - def __init__(self, app: Any, attrs_var: ContextVar[Dict[str, Any]]) -> None: + def __init__( + self, + app: Any, + attrs_var: ContextVar[Dict[str, Any]], + token_verifier: Optional[TokenVerifier], + ) -> None: super().__init__(app) self._attrs_var = attrs_var + self._token_verifier = token_verifier async def dispatch(self, request: Request, call_next: Any) -> Response: - token = self._attrs_var.set(_extract_ias_attrs(request)) + token = self._attrs_var.set(_extract_ias_attrs(request, self._token_verifier)) try: return await call_next(request) finally: @@ -39,38 +45,71 @@ async def dispatch(self, request: Request, call_next: Any) -> Response: class StarletteIASTelemetryMiddleware(TelemetryMiddleware): - """Starlette/FastAPI middleware that extracts IAS JWT claims as telemetry attributes. + """Starlette/FastAPI middleware that extracts verified IAS JWT claims as telemetry attributes. - Reads the ``Authorization: Bearer `` header on each request, - parses it as an IAS JWT, and exposes the following as span attributes: + Reads the ``Authorization: Bearer `` header on each request, passes it through + the provided ``token_verifier``, and exposes the following as span attributes on success: - ``sap.tenancy.tenant_id`` from the ``sap_gtid`` claim - ``user.id`` from the ``user_uuid`` claim - If the header is absent or the token cannot be parsed, no attributes are set - and the request continues normally. + The ``x-sap-origin`` header (trigger type, not JWT identity) is always stamped when + present, regardless of token verification outcome. - Each instance owns its own ContextVar to prevent cross-talk when multiple - middleware instances are registered on the same app. + If ``token_verifier`` is ``None`` (the default), **no identity attributes are stamped** + and a warning is logged once at construction. This is a safe default — the app runs + normally but ``sap.tenancy.tenant_id`` and ``user.id`` will be absent from spans until + a verifier is supplied. See the IAS user guide for how to implement a verifier. + + If the verifier raises for any reason (bad signature, wrong issuer, expired token, + unknown algorithm, etc.), the identity attributes are silently omitted and the request + continues normally. + + Each instance owns its own ContextVar to prevent cross-talk when multiple middleware + instances are registered on the same app. + + Args: + app: The Starlette/FastAPI application instance. + token_verifier: A callable that receives the raw ``Authorization`` header value + and returns a :class:`~sap_cloud_sdk.ias.VerifiedIASClaims` on success, or + raises on any invalid token. If ``None``, identity attributes are disabled. Usage:: from starlette.applications import Starlette from sap_cloud_sdk.core.telemetry import auto_instrument from sap_cloud_sdk.core.telemetry.middleware import StarletteIASTelemetryMiddleware + from sap_cloud_sdk.ias import VerifiedIASClaims, parse_token + + def my_verifier(authorization: str) -> VerifiedIASClaims: + # Your platform (e.g. Kyma Istio) already verified the JWT. + # See the IAS user guide for a full JWKS-based verifier example. + return VerifiedIASClaims(claims=parse_token(authorization)) app = Starlette(...) - auto_instrument(middlewares=[StarletteIASTelemetryMiddleware(app=app)]) + auto_instrument(middlewares=[StarletteIASTelemetryMiddleware(app=app, token_verifier=my_verifier)]) """ - def __init__(self, app: Any) -> None: + def __init__(self, app: Any, token_verifier: Optional[TokenVerifier] = None) -> None: self.app = app + self._token_verifier = token_verifier self._attrs_var: ContextVar[Dict[str, Any]] = ContextVar( f"ias_attrs_{id(self)}", default={} ) + if token_verifier is None: + logger.warning( + "StarletteIASTelemetryMiddleware: no token_verifier supplied — " + "sap.tenancy.tenant_id and user.id will NOT be stamped on spans. " + "Supply a token_verifier to enable verified IAS identity attributes. " + "See the IAS user guide for a JWKS-based verifier example." + ) def register(self) -> None: """Register the IAS JWT middleware with ``self.app``.""" - self.app.add_middleware(_IASMiddleware, attrs_var=self._attrs_var) + self.app.add_middleware( + _IASMiddleware, + attrs_var=self._attrs_var, + token_verifier=self._token_verifier, + ) logger.info("Registered IAS telemetry middleware on %r", self.app) def get_attributes(self) -> Dict[str, Any]: @@ -78,23 +117,40 @@ def get_attributes(self) -> Dict[str, Any]: return self._attrs_var.get() -def _extract_ias_attrs(request: Request) -> Dict[str, Any]: - """Parse the Authorization header and return telemetry attributes.""" +def _extract_ias_attrs( + request: Request, token_verifier: Optional[TokenVerifier] +) -> Dict[str, Any]: + """Extract telemetry attributes from the request. + + ``x-sap-origin`` (trigger type) is always included when present — it is a plain + request header, not JWT identity data. + + Identity attributes (``sap.tenancy.tenant_id``, ``user.id``) are included only when + ``token_verifier`` is provided and succeeds for the ``Authorization`` header. + """ + attrs: Dict[str, Any] = {} + + # x-sap-origin is not JWT identity — stamp it regardless of verification outcome. + origin = request.headers.get("x-sap-origin") + if origin: + attrs[ATTR_SAP_TRIGGER_TYPE] = origin + auth = request.headers.get("authorization", "") if not auth: - return {} + return attrs + + if token_verifier is None: + return attrs # fail-closed for identity; warned once at construction + try: - claims = parse_token(auth) + verified = token_verifier(auth) except Exception as e: - logger.debug("IAS token parsing failed, skipping telemetry attrs: %s", e) - return {} + logger.debug("IAS token verification failed, skipping identity attrs: %s", e) + return attrs - attrs: Dict[str, Any] = {} + claims = verified.claims if claims.sap_gtid: attrs[ATTR_SAP_TENANT_ID] = claims.sap_gtid if claims.user_uuid: attrs[ATTR_USER_ID] = claims.user_uuid - origin = request.headers.get("x-sap-origin") - if origin: - attrs[ATTR_SAP_TRIGGER_TYPE] = origin return attrs diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index 019bc1ac..4c0dbedb 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -267,21 +267,41 @@ auto_instrument(middlewares=[MyMiddleware(app=app)]) ### Built-in: `StarletteIASTelemetryMiddleware` -For Starlette/FastAPI apps with IAS authentication, the SDK ships a ready-to-use middleware that reads the `Authorization: Bearer ` header on each request, parses it as an IAS JWT, and injects: +For Starlette/FastAPI apps with IAS authentication, the SDK ships a ready-to-use middleware that reads the `Authorization: Bearer ` header on each request and, after successful token verification, injects: - `sap.tenancy.tenant_id` from the `sap_gtid` claim - `user.id` from the `user_uuid` claim -If the header is absent or the token cannot be parsed, no attributes are set and the request continues normally. +The `x-sap-origin` header (trigger type) is always stamped when present, regardless of token verification outcome. + +#### `token_verifier` parameter + +A `token_verifier` callable is **required to enable identity attributes**. Without it, the middleware logs a one-time warning and stamps **no** `sap.tenancy.tenant_id` / `user.id`. This is a safe default — the app runs normally, but identity attributes are absent from spans until you supply a verifier. ```python from starlette.applications import Starlette from sap_cloud_sdk.core.telemetry import auto_instrument from sap_cloud_sdk.core.telemetry.middleware import StarletteIASTelemetryMiddleware +from sap_cloud_sdk.ias import VerifiedIASClaims, parse_token + +# Option A: platform pre-verified (e.g. Kyma Istio / UCL already verified the JWT) +def platform_pre_verified(authorization: str) -> VerifiedIASClaims: + return VerifiedIASClaims(claims=parse_token(authorization)) + +# Option B: real JWKS verification (see the IAS user guide for the full implementation) +# verifier = make_ias_verifier(jwks_url="https://.accounts.ondemand.com/oauth2/certs", +# issuer="https://.accounts.ondemand.com", +# audience="") app = Starlette(...) -auto_instrument(middlewares=[StarletteIASTelemetryMiddleware(app=app)]) +auto_instrument(middlewares=[ + StarletteIASTelemetryMiddleware(app=app, token_verifier=platform_pre_verified) +]) ``` +If the verifier raises for any reason (bad signature, wrong issuer, expired token, etc.), identity attributes are silently omitted and the request continues normally. + +See the [IAS user guide](../../ias/user-guide.md#verified-claims-security-sensitive-consumers) for a full JWKS-based verifier implementation. + --- ## Configuration diff --git a/src/sap_cloud_sdk/ias/__init__.py b/src/sap_cloud_sdk/ias/__init__.py index 88df1774..3a4dea69 100644 --- a/src/sap_cloud_sdk/ias/__init__.py +++ b/src/sap_cloud_sdk/ias/__init__.py @@ -12,11 +12,13 @@ print(claims.email) # user email (when email scope requested) """ -from sap_cloud_sdk.ias._token import IASClaims, parse_token +from sap_cloud_sdk.ias._token import IASClaims, TokenVerifier, VerifiedIASClaims, parse_token from sap_cloud_sdk.ias.exceptions import IASTokenError __all__ = [ "IASClaims", - "parse_token", "IASTokenError", + "TokenVerifier", + "VerifiedIASClaims", + "parse_token", ] diff --git a/src/sap_cloud_sdk/ias/_token.py b/src/sap_cloud_sdk/ias/_token.py index c2ebc71c..e32bce01 100644 --- a/src/sap_cloud_sdk/ias/_token.py +++ b/src/sap_cloud_sdk/ias/_token.py @@ -7,7 +7,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Union import jwt @@ -102,6 +102,30 @@ class IASClaims: custom_attributes: Dict[str, Any] = field(default_factory=dict) +@dataclass(frozen=True) +class VerifiedIASClaims: + """Claims proven to originate from a successfully verified IAS JWT. + + Construct this ONLY after verifying the token's signature, issuer, + audience, algorithm, and time constraints. Its presence is the SDK's + provenance marker for security-sensitive consumers such as telemetry + identity stamping. + """ + + claims: IASClaims + + +TokenVerifier = Callable[[str], VerifiedIASClaims] +"""Callable contract for IAS JWT verifiers. + +Receives the raw ``Authorization`` header value (may include the ``"Bearer "`` +prefix) and must raise (fail closed) on any invalid token — bad signature, +wrong issuer/audience, expired, not-yet-valid, or unknown algorithm. + +Returns a :class:`VerifiedIASClaims` instance on success. +""" + + def parse_token(token: str) -> IASClaims: """Parse an SAP IAS JWT token and return its claims. diff --git a/src/sap_cloud_sdk/ias/user-guide.md b/src/sap_cloud_sdk/ias/user-guide.md index 4f6c9c9f..155aa89a 100644 --- a/src/sap_cloud_sdk/ias/user-guide.md +++ b/src/sap_cloud_sdk/ias/user-guide.md @@ -27,7 +27,104 @@ print(claims.sub) # OIDC subject identifier print(claims.email) # user email (when email scope was requested) ``` -### Claims Reference +--- + +## Verified Claims (Security-Sensitive Consumers) + +`parse_token` is a low-level claim extractor — it does not authenticate the token. For security-sensitive consumers (telemetry identity, audit attribution) use the `TokenVerifier` / `VerifiedIASClaims` pattern to prove that a token was cryptographically verified before its claims are used. + +```python +from sap_cloud_sdk.ias import VerifiedIASClaims, TokenVerifier +``` + +### `VerifiedIASClaims` + +A frozen dataclass wrapper around `IASClaims`. Its existence at runtime means "these claims came through a verifier." Create an instance **only** inside your verifier function, after a successful signature check. + +```python +@dataclass(frozen=True) +class VerifiedIASClaims: + claims: IASClaims +``` + +### `TokenVerifier` + +A type alias for the verifier callable contract: + +```python +TokenVerifier = Callable[[str], VerifiedIASClaims] +``` + +The callable receives the raw `Authorization` header value (may include `"Bearer "`), **must raise** (fail closed) on any invalid token, and returns `VerifiedIASClaims` on success. + +### Implementing a JWKS-based verifier + +For apps deployed on SAP BTP where JWT signature verification is not already handled by the platform (e.g. Kyma Istio JWT `RequestAuthentication` policy), implement a real JWKS verifier using `PyJWKClient` from `PyJWT`: + +```python +import jwt +from jwt import PyJWKClient +from sap_cloud_sdk.ias import parse_token, VerifiedIASClaims, IASTokenError + + +def make_ias_verifier(jwks_url: str, issuer: str, audience: str): + """Build a token verifier backed by IAS JWKS key rotation. + + Args: + jwks_url: IAS JWKS endpoint, e.g. "https://.accounts.ondemand.com/oauth2/certs" + issuer: Expected token issuer, e.g. "https://.accounts.ondemand.com" + audience: Expected audience / client_id of this application + + Returns: + A TokenVerifier callable. + """ + jwk_client = PyJWKClient(jwks_url) # caches keys; thread-safe + + def verify(authorization: str) -> VerifiedIASClaims: + raw = authorization.removeprefix("Bearer ").removeprefix("bearer ").strip() + try: + signing_key = jwk_client.get_signing_key_from_jwt(raw) # selects by kid + jwt.decode( + raw, + signing_key.key, + algorithms=["RS256", "ES256"], # pin asymmetric algs; NEVER "none" or HS* + issuer=issuer, + audience=audience, + options={"require": ["exp", "iss", "aud"]}, + ) + except jwt.exceptions.PyJWTError as e: + raise IASTokenError(f"IAS token verification failed: {e}") from e + return VerifiedIASClaims(claims=parse_token(raw)) + + return verify +``` + +**Required validations:** `PyJWT` enforces signature via the JWKS key, `kid` selection via `PyJWKClient`, issuer (`iss`), audience (`aud`/`azp`), expiration (`exp`), and not-before (`nbf`). Do **not** accept `alg=none`, HS256/HS512 (symmetric), or tokens without `exp`/`iss`/`aud`. + +**Dependency note:** RSA/EC signature verification requires the `cryptography` package. Install it alongside `PyJWT`: + +```bash +pip install "PyJWT[cryptography]" +``` + +### Platform pre-verified adapter + +For apps where the deployment platform (e.g. Kyma Istio `RequestAuthentication`, UCL mTLS) has already verified the JWT before the request reaches your app, you can use a thin adapter that trusts the platform verification and delegates claim extraction to `parse_token`: + +```python +from sap_cloud_sdk.ias import parse_token, VerifiedIASClaims + +def platform_pre_verified(authorization: str) -> VerifiedIASClaims: + # Platform auth (e.g. Istio/UCL) verified the JWT before routing here. + # parse_token() is safe here as a claim extractor only. + return VerifiedIASClaims(claims=parse_token(authorization)) +``` + +> **Important:** only use this adapter when you have confirmed that your deployment platform enforces JWT verification on every request that carries an `Authorization` header. + +--- + +## Claims Reference All fields on `IASClaims` are `Optional` — claims absent from the token are `None`. diff --git a/tests/core/unit/telemetry/middleware/test_starlette_a2a.py b/tests/core/unit/telemetry/middleware/test_starlette_a2a.py index e0cfdda3..62e18b35 100644 --- a/tests/core/unit/telemetry/middleware/test_starlette_a2a.py +++ b/tests/core/unit/telemetry/middleware/test_starlette_a2a.py @@ -1,22 +1,23 @@ """Tests for StarletteIASTelemetryMiddleware.""" +import logging import pytest -from unittest.mock import MagicMock, AsyncMock, patch +from unittest.mock import MagicMock, AsyncMock from sap_cloud_sdk.core.telemetry.constants import ATTR_SAP_TRIGGER_TYPE, ATTR_SAP_TENANT_ID, ATTR_USER_ID from sap_cloud_sdk.core.telemetry.middleware.starlette_a2a import ( StarletteIASTelemetryMiddleware, _extract_ias_attrs, ) +from sap_cloud_sdk.ias import IASClaims, IASTokenError, VerifiedIASClaims -_PATCH_PARSE = "sap_cloud_sdk.core.telemetry.middleware.starlette_a2a.parse_token" +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- -def _make_claims(sap_gtid=None, user_uuid=None): - claims = MagicMock() - claims.sap_gtid = sap_gtid - claims.user_uuid = user_uuid - return claims +def _make_verified(sap_gtid=None, user_uuid=None) -> VerifiedIASClaims: + return VerifiedIASClaims(claims=IASClaims(sap_gtid=sap_gtid, user_uuid=user_uuid)) def _make_request(headers: dict): @@ -25,25 +26,43 @@ def _make_request(headers: dict): return request +def _passing_verifier(sap_gtid=None, user_uuid=None): + """Returns a verifier that always succeeds with the given claim values.""" + def verify(token: str) -> VerifiedIASClaims: + return _make_verified(sap_gtid=sap_gtid, user_uuid=user_uuid) + return verify + + +def _failing_verifier(exc=None): + """Returns a verifier that always raises (simulates invalid token).""" + def verify(token: str) -> VerifiedIASClaims: + raise (exc or IASTokenError("verification failed")) + return verify + + +# --------------------------------------------------------------------------- +# StarletteIASTelemetryMiddleware construction +# --------------------------------------------------------------------------- + class TestStarletteIASTelemetryMiddleware: def test_register_calls_add_middleware_on_self_app(self): app = MagicMock() - mw = StarletteIASTelemetryMiddleware(app=app) + mw = StarletteIASTelemetryMiddleware(app=app, token_verifier=_passing_verifier()) mw.register() app.add_middleware.assert_called_once() def test_get_attributes_returns_empty_outside_request(self): - mw = StarletteIASTelemetryMiddleware(app=MagicMock()) + mw = StarletteIASTelemetryMiddleware(app=MagicMock(), token_verifier=_passing_verifier()) assert mw.get_attributes() == {} def test_each_instance_has_independent_context_var(self): - mw1 = StarletteIASTelemetryMiddleware(app=MagicMock()) - mw2 = StarletteIASTelemetryMiddleware(app=MagicMock()) + mw1 = StarletteIASTelemetryMiddleware(app=MagicMock(), token_verifier=_passing_verifier()) + mw2 = StarletteIASTelemetryMiddleware(app=MagicMock(), token_verifier=_passing_verifier()) assert mw1._attrs_var is not mw2._attrs_var def test_two_instances_do_not_interfere(self): - mw1 = StarletteIASTelemetryMiddleware(app=MagicMock()) - mw2 = StarletteIASTelemetryMiddleware(app=MagicMock()) + mw1 = StarletteIASTelemetryMiddleware(app=MagicMock(), token_verifier=_passing_verifier()) + mw2 = StarletteIASTelemetryMiddleware(app=MagicMock(), token_verifier=_passing_verifier()) t1 = mw1._attrs_var.set({ATTR_SAP_TENANT_ID: "tenant-a"}) t2 = mw2._attrs_var.set({ATTR_USER_ID: "user-b"}) @@ -54,65 +73,135 @@ def test_two_instances_do_not_interfere(self): mw1._attrs_var.reset(t1) mw2._attrs_var.reset(t2) + def test_no_verifier_logs_warning(self, caplog): + with caplog.at_level(logging.WARNING): + StarletteIASTelemetryMiddleware(app=MagicMock(), token_verifier=None) + assert any("token_verifier" in r.message for r in caplog.records) + + def test_verifier_provided_no_warning(self, caplog): + with caplog.at_level(logging.WARNING): + StarletteIASTelemetryMiddleware(app=MagicMock(), token_verifier=_passing_verifier()) + assert not any("token_verifier" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# _extract_ias_attrs: core security behaviour +# --------------------------------------------------------------------------- class TestExtractIasAttrs: - def test_extracts_tenant_and_user(self): - claims = _make_claims(sap_gtid="t1", user_uuid="u1") + + # --- fail-closed default (no verifier) --- + + def test_no_verifier_stamps_nothing(self): + request = _make_request({"authorization": "Bearer some.jwt.token"}) + result = _extract_ias_attrs(request, None) + assert ATTR_SAP_TENANT_ID not in result + assert ATTR_USER_ID not in result + + def test_no_verifier_with_auth_returns_empty(self): + request = _make_request({"authorization": "Bearer tok"}) + assert _extract_ias_attrs(request, None) == {} + + # --- origin header is independent of verification --- + + def test_origin_stamped_when_no_verifier(self): + request = _make_request({"authorization": "Bearer tok", "x-sap-origin": "ui5"}) + result = _extract_ias_attrs(request, None) + assert result == {ATTR_SAP_TRIGGER_TYPE: "ui5"} + assert ATTR_SAP_TENANT_ID not in result + + def test_origin_stamped_when_verifier_raises(self): + request = _make_request({ + "authorization": "Bearer forged.jwt", + "x-sap-origin": "job", + }) + result = _extract_ias_attrs(request, _failing_verifier()) + assert result == {ATTR_SAP_TRIGGER_TYPE: "job"} + assert ATTR_SAP_TENANT_ID not in result + assert ATTR_USER_ID not in result + + def test_origin_stamped_on_verified_path(self): + request = _make_request({ + "authorization": "Bearer tok", + "x-sap-origin": "ui5", + }) + result = _extract_ias_attrs(request, _passing_verifier(sap_gtid="t1", user_uuid="u1")) + assert result[ATTR_SAP_TRIGGER_TYPE] == "ui5" + assert result[ATTR_SAP_TENANT_ID] == "t1" + assert result[ATTR_USER_ID] == "u1" + + def test_origin_omitted_when_absent(self): + request = _make_request({"authorization": "Bearer tok"}) + result = _extract_ias_attrs(request, _passing_verifier(sap_gtid="t1")) + assert ATTR_SAP_TRIGGER_TYPE not in result + + # --- verified path stamps identity --- + + def test_verified_token_stamps_tenant_and_user(self): request = _make_request({"authorization": "Bearer tok"}) - with patch(_PATCH_PARSE, return_value=claims): - result = _extract_ias_attrs(request) - assert result == {ATTR_SAP_TENANT_ID: "t1", ATTR_USER_ID: "u1"} + result = _extract_ias_attrs(request, _passing_verifier(sap_gtid="t1", user_uuid="u1")) + assert result[ATTR_SAP_TENANT_ID] == "t1" + assert result[ATTR_USER_ID] == "u1" - def test_omits_missing_tenant(self): - claims = _make_claims(sap_gtid=None, user_uuid="u1") + def test_omits_missing_tenant_on_verified_path(self): request = _make_request({"authorization": "Bearer tok"}) - with patch(_PATCH_PARSE, return_value=claims): - result = _extract_ias_attrs(request) + result = _extract_ias_attrs(request, _passing_verifier(sap_gtid=None, user_uuid="u1")) assert result == {ATTR_USER_ID: "u1"} assert ATTR_SAP_TENANT_ID not in result - def test_omits_missing_user(self): - claims = _make_claims(sap_gtid="t1", user_uuid=None) + def test_omits_missing_user_on_verified_path(self): request = _make_request({"authorization": "Bearer tok"}) - with patch(_PATCH_PARSE, return_value=claims): - result = _extract_ias_attrs(request) + result = _extract_ias_attrs(request, _passing_verifier(sap_gtid="t1", user_uuid=None)) assert result == {ATTR_SAP_TENANT_ID: "t1"} assert ATTR_USER_ID not in result + # --- regression: forged / invalid tokens must not stamp identity --- + + def test_forged_attacker_token_stamps_nothing(self): + """Attacker-signed token with victim tenant/user must not populate identity attrs.""" + request = _make_request({"authorization": "Bearer attacker.signed.jwt"}) + result = _extract_ias_attrs(request, _failing_verifier(IASTokenError("bad signature"))) + assert ATTR_SAP_TENANT_ID not in result + assert ATTR_USER_ID not in result + + def test_verification_failure_does_not_inherit_victim_identity(self): + """Even if the token carries victim-named claims, a failed verifier means no stamp.""" + request = _make_request({"authorization": "Bearer victim.claimed.token"}) + result = _extract_ias_attrs(request, _failing_verifier(IASTokenError("alg=none rejected"))) + assert result == {} # no spillover of any kind + + @pytest.mark.parametrize("exc_msg", [ + "alg=none rejected", + "HS256 confusion attack", + "unknown kid", + "wrong issuer", + "wrong audience", + "token expired", + "token not yet valid", + "signature verification failed", + ]) + def test_invalid_token_variants_stamp_nothing(self, exc_msg): + request = _make_request({"authorization": "Bearer bad.token"}) + result = _extract_ias_attrs(request, _failing_verifier(IASTokenError(exc_msg))) + assert ATTR_SAP_TENANT_ID not in result + assert ATTR_USER_ID not in result + + # --- no auth header --- + def test_returns_empty_when_no_auth_header(self): request = _make_request({}) - with patch(_PATCH_PARSE) as mock_parse: - result = _extract_ias_attrs(request) - mock_parse.assert_not_called() - assert result == {} - - def test_returns_empty_on_parse_error(self): - request = _make_request({"authorization": "Bearer bad"}) - with patch(_PATCH_PARSE, side_effect=ValueError("bad token")): - result = _extract_ias_attrs(request) - assert result == {} - - def test_returns_empty_when_both_claims_absent(self): - claims = _make_claims(sap_gtid=None, user_uuid=None) - request = _make_request({"authorization": "Bearer tok"}) - with patch(_PATCH_PARSE, return_value=claims): - result = _extract_ias_attrs(request) - assert result == {} + assert _extract_ias_attrs(request, _passing_verifier(sap_gtid="t1")) == {} - def test_includes_origin_header_when_present(self): - claims = _make_claims(sap_gtid="t1", user_uuid="u1") - request = _make_request({"authorization": "Bearer tok", "x-sap-origin": "ui5"}) - with patch(_PATCH_PARSE, return_value=claims): - result = _extract_ias_attrs(request) - assert result[ATTR_SAP_TRIGGER_TYPE] == "ui5" + def test_no_auth_header_origin_still_stamped(self): + request = _make_request({"x-sap-origin": "ui5"}) + result = _extract_ias_attrs(request, _passing_verifier(sap_gtid="t1")) + assert result == {ATTR_SAP_TRIGGER_TYPE: "ui5"} + assert ATTR_SAP_TENANT_ID not in result - def test_omits_origin_attr_when_header_absent(self): - claims = _make_claims(sap_gtid="t1", user_uuid="u1") - request = _make_request({"authorization": "Bearer tok"}) - with patch(_PATCH_PARSE, return_value=claims): - result = _extract_ias_attrs(request) - assert ATTR_SAP_TRIGGER_TYPE not in result +# --------------------------------------------------------------------------- +# Inner middleware dispatch +# --------------------------------------------------------------------------- class TestInnerMiddlewareDispatch: def _get_inner_class_and_kwargs(self, mw: StarletteIASTelemetryMiddleware): @@ -124,8 +213,10 @@ def _get_inner_class_and_kwargs(self, mw: StarletteIASTelemetryMiddleware): @pytest.mark.anyio async def test_sets_attrs_in_context_var_during_request(self): - mw = StarletteIASTelemetryMiddleware(app=MagicMock()) - claims = _make_claims(sap_gtid="tenant-1", user_uuid="user-1") + mw = StarletteIASTelemetryMiddleware( + app=MagicMock(), + token_verifier=_passing_verifier(sap_gtid="tenant-1", user_uuid="user-1"), + ) inner_cls, kwargs = self._get_inner_class_and_kwargs(mw) request = _make_request({"authorization": "Bearer tok"}) @@ -136,28 +227,30 @@ async def call_next(req): return MagicMock() inner = inner_cls(app=MagicMock(), **kwargs) - with patch(_PATCH_PARSE, lambda t: claims): - await inner.dispatch(request, call_next) + await inner.dispatch(request, call_next) assert captured == {ATTR_SAP_TENANT_ID: "tenant-1", ATTR_USER_ID: "user-1"} @pytest.mark.anyio async def test_context_var_reset_after_request(self): - mw = StarletteIASTelemetryMiddleware(app=MagicMock()) - claims = _make_claims(sap_gtid="t1", user_uuid="u1") + mw = StarletteIASTelemetryMiddleware( + app=MagicMock(), + token_verifier=_passing_verifier(sap_gtid="t1", user_uuid="u1"), + ) inner_cls, kwargs = self._get_inner_class_and_kwargs(mw) request = _make_request({"authorization": "Bearer tok"}) inner = inner_cls(app=MagicMock(), **kwargs) - with patch(_PATCH_PARSE, lambda t: claims): - await inner.dispatch(request, AsyncMock(return_value=MagicMock())) + await inner.dispatch(request, AsyncMock(return_value=MagicMock())) assert mw._attrs_var.get() == {} @pytest.mark.anyio async def test_context_var_reset_on_exception(self): - mw = StarletteIASTelemetryMiddleware(app=MagicMock()) - claims = _make_claims(sap_gtid="t1", user_uuid="u1") + mw = StarletteIASTelemetryMiddleware( + app=MagicMock(), + token_verifier=_passing_verifier(sap_gtid="t1", user_uuid="u1"), + ) inner_cls, kwargs = self._get_inner_class_and_kwargs(mw) request = _make_request({"authorization": "Bearer tok"}) @@ -166,15 +259,17 @@ async def raises(req): raise RuntimeError("downstream") inner = inner_cls(app=MagicMock(), **kwargs) - with patch(_PATCH_PARSE, lambda t: claims): - with pytest.raises(RuntimeError): - await inner.dispatch(request, raises) + with pytest.raises(RuntimeError): + await inner.dispatch(request, raises) assert mw._attrs_var.get() == {} @pytest.mark.anyio async def test_no_auth_header_sets_empty_attrs(self): - mw = StarletteIASTelemetryMiddleware(app=MagicMock()) + mw = StarletteIASTelemetryMiddleware( + app=MagicMock(), + token_verifier=_passing_verifier(sap_gtid="t1"), + ) inner_cls, kwargs = self._get_inner_class_and_kwargs(mw) request = _make_request({}) @@ -189,12 +284,37 @@ async def call_next(req): assert captured == {} + @pytest.mark.anyio + async def test_forged_token_sets_empty_attrs_during_dispatch(self): + mw = StarletteIASTelemetryMiddleware( + app=MagicMock(), + token_verifier=_failing_verifier(IASTokenError("bad sig")), + ) + inner_cls, kwargs = self._get_inner_class_and_kwargs(mw) + + request = _make_request({"authorization": "Bearer attacker.jwt"}) + captured = {} + + async def call_next(req): + captured.update(mw._attrs_var.get()) + return MagicMock() + + inner = inner_cls(app=MagicMock(), **kwargs) + await inner.dispatch(request, call_next) + + assert ATTR_SAP_TENANT_ID not in captured + assert ATTR_USER_ID not in captured + @pytest.mark.anyio async def test_two_instances_independent_during_dispatch(self): - mw1 = StarletteIASTelemetryMiddleware(app=MagicMock()) - mw2 = StarletteIASTelemetryMiddleware(app=MagicMock()) - claims1 = _make_claims(sap_gtid="tenant-1", user_uuid=None) - claims2 = _make_claims(sap_gtid=None, user_uuid="user-2") + mw1 = StarletteIASTelemetryMiddleware( + app=MagicMock(), + token_verifier=_passing_verifier(sap_gtid="tenant-1", user_uuid=None), + ) + mw2 = StarletteIASTelemetryMiddleware( + app=MagicMock(), + token_verifier=_passing_verifier(sap_gtid=None, user_uuid="user-2"), + ) inner1_cls, kwargs1 = self._get_inner_class_and_kwargs(mw1) inner2_cls, kwargs2 = self._get_inner_class_and_kwargs(mw2) inner1 = inner1_cls(app=MagicMock(), **kwargs1) @@ -211,10 +331,8 @@ async def next2(r): captured2.update(mw2._attrs_var.get()) return MagicMock() - with patch(_PATCH_PARSE, lambda t: claims1): - await inner1.dispatch(req, next1) - with patch(_PATCH_PARSE, lambda t: claims2): - await inner2.dispatch(req, next2) + await inner1.dispatch(req, next1) + await inner2.dispatch(req, next2) assert captured1 == {ATTR_SAP_TENANT_ID: "tenant-1"} assert captured2 == {ATTR_USER_ID: "user-2"} diff --git a/tests/ias/unit/test_token.py b/tests/ias/unit/test_token.py index db7ffc9b..73944465 100644 --- a/tests/ias/unit/test_token.py +++ b/tests/ias/unit/test_token.py @@ -3,7 +3,7 @@ import pytest import jwt as pyjwt -from sap_cloud_sdk.ias import parse_token, IASClaims, IASTokenError +from sap_cloud_sdk.ias import IASClaims, IASTokenError, TokenVerifier, VerifiedIASClaims, parse_token def _make_token(payload: dict) -> str: @@ -158,3 +158,31 @@ def test_known_claims_not_duplicated_in_custom_attributes(self): assert "sub" not in claims.custom_attributes assert "user_uuid" not in claims.custom_attributes assert claims.custom_attributes == {"unknown_claim": "yes"} + + +class TestVerifiedIASClaims: + def test_wraps_ias_claims(self): + inner = IASClaims(sub="u1", app_tid="t1") + verified = VerifiedIASClaims(claims=inner) + assert verified.claims is inner + + def test_claims_accessible_via_attribute(self): + inner = IASClaims(user_uuid="uid-123", sap_gtid="gtid-abc") + verified = VerifiedIASClaims(claims=inner) + assert verified.claims.user_uuid == "uid-123" + assert verified.claims.sap_gtid == "gtid-abc" + + def test_is_frozen(self): + inner = IASClaims(sub="x") + verified = VerifiedIASClaims(claims=inner) + with pytest.raises((AttributeError, TypeError)): + verified.claims = IASClaims(sub="y") # type: ignore[misc] + + def test_token_verifier_type_is_callable(self): + def my_verifier(token: str) -> VerifiedIASClaims: + return VerifiedIASClaims(claims=IASClaims(sub="x")) + + # TokenVerifier is a type alias — verify the callable protocol is satisfied + verifier: TokenVerifier = my_verifier + result = verifier("Bearer tok") + assert isinstance(result, VerifiedIASClaims) diff --git a/uv.lock b/uv.lock index 92e88dcb..3a1eb688 100644 --- a/uv.lock +++ b/uv.lock @@ -925,7 +925,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/8fd452fd81adb9ec79c8275c1375702ab0fd6bee4952da12eaa09b9508d8/greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", size = 623515, upload-time = "2026-05-20T14:09:07.853Z" }, { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bc/c318aa9f3ffc77320fddcee3d892be957b42e2ff947198d9450b004f3a38/greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", size = 418439, upload-time = "2026-05-20T14:01:38.446Z" }, { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, @@ -933,7 +935,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -941,7 +945,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, @@ -949,7 +955,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, @@ -957,14 +965,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, @@ -972,7 +984,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, @@ -2583,6 +2597,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/a3/f2c38b8cd448ee536870bc7771d863ec57232446c850124adcd08bc8e3c0/opentelemetry_instrumentation_writer-0.52.3-py3-none-any.whl", hash = "sha256:c5155413d6f4e6ae7b842d6b1b2df21a244969c669e3ffb78a7ef7c57afb1da5", size = 11516, upload-time = "2026-02-10T14:54:43.197Z" }, ] +[[package]] +name = "opentelemetry-processor-baggage" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/0d/4afee20490ef53a449b1781b0671d84858742a2ccfb01c08de398a5d1ccd/opentelemetry_processor_baggage-0.61b0.tar.gz", hash = "sha256:4d1d2a624e3aa9a8b6c6d1f560ba2951f97acf875f57502a274c5078043a69d5", size = 7573, upload-time = "2026-03-04T14:20:54.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/24/0ef2cf49e6ac9b2b422400abbf528230a409c9e174572f2d13e2dff7ec7c/opentelemetry_processor_baggage-0.61b0-py3-none-any.whl", hash = "sha256:f6b5937e93bda8f380d8f5f667355c7d127e9296b38dfacf39fd328ab410262c", size = 8881, upload-time = "2026-03-04T14:20:05.25Z" }, +] + [[package]] name = "opentelemetry-proto" version = "1.42.1" @@ -3685,7 +3713,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.35.0" +version = "0.35.1" source = { editable = "." } dependencies = [ { name = "grpcio" }, @@ -3697,6 +3725,7 @@ dependencies = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-instrumentation-langchain" }, + { name = "opentelemetry-processor-baggage" }, { name = "opentelemetry-sdk" }, { name = "protobuf" }, { name = "protovalidate" }, @@ -3715,9 +3744,6 @@ extensibility = [ langchain = [ { name = "langchain-core" }, ] -langgraph = [ - { name = "langgraph" }, -] starlette = [ { name = "starlette" }, ] @@ -3750,13 +3776,13 @@ requires-dist = [ { name = "hatchling", specifier = "~=1.27.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "langchain-core", marker = "extra == 'langchain'", specifier = ">=1.2.7" }, - { name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, { name = "mcp", specifier = ">=1.1.0" }, { name = "minio", specifier = "~=7.2.16" }, { name = "opentelemetry-api", specifier = ">=1.42.1" }, { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = "~=1.42.1" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.42.1" }, { name = "opentelemetry-instrumentation-langchain", specifier = ">=0.61.0" }, + { name = "opentelemetry-processor-baggage", specifier = "~=0.61b0" }, { name = "opentelemetry-sdk", specifier = ">=1.42.1" }, { name = "protobuf", specifier = ">=4.25.0" }, { name = "protovalidate", specifier = ">=0.13.0" }, @@ -3768,7 +3794,7 @@ requires-dist = [ { name = "starlette", marker = "extra == 'starlette'", specifier = ">=0.40.0" }, { name = "traceloop-sdk", specifier = "~=0.61.0" }, ] -provides-extras = ["extensibility", "starlette", "langchain", "langgraph"] +provides-extras = ["extensibility", "starlette", "langchain"] [package.metadata.requires-dev] dev = [ From 15eb81f38a2c7c56e9cf8836ba74c72c28bc3715 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Fri, 2 Oct 2026 14:33:24 -0300 Subject: [PATCH 2/2] feat(ias): add IASVerifier with auto-config for zero-config JWKS verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The middleware previously required consumers to implement their own verifier or left identity attributes unverified. IASVerifier auto-configures from VCAP_SERVICES (CF) or IAS_URL (K8s) so agents using the SDK get working signature verification with no extra code. - Add IASVerifier: JWKS-backed verifier, PyJWKClient with key caching, RS256/ES256 algorithm pinning, issuer/audience/exp/nbf enforcement - Add IASVerifier.from_env(): resolves VCAP_SERVICES → identity → xsuaa, then IAS_URL/IAS_CLIENT_ID env vars; raises IASConfigError if nothing found - StarletteIASTelemetryMiddleware auto-calls IASVerifier.from_env() when no token_verifier supplied; logs WARNING + disables identity attrs on failure - Promote cryptography>=44.0.0 to runtime dep (required for RSA/EC key ops) - Add IASConfigError, IASVerifier to sap_cloud_sdk.ias public API - Add 22 unit tests for IASVerifier (from_env variants + __call__ scenarios) - Rewrite middleware tests: add auto-config coverage + retain all regression tests for forged/invalid tokens --- pyproject.toml | 1 + .../telemetry/middleware/starlette_a2a.py | 58 ++-- src/sap_cloud_sdk/ias/__init__.py | 5 +- src/sap_cloud_sdk/ias/_verifier.py | 161 ++++++++++ .../middleware/test_starlette_a2a.py | 75 ++++- tests/ias/unit/test_verifier.py | 285 ++++++++++++++++++ uv.lock | 26 +- 7 files changed, 559 insertions(+), 52 deletions(-) create mode 100644 src/sap_cloud_sdk/ias/_verifier.py create mode 100644 tests/ias/unit/test_verifier.py diff --git a/pyproject.toml b/pyproject.toml index a9e4a46a..94f1800c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "opentelemetry-instrumentation-langchain>=0.61.0", "httpx>=0.27.0", "PyJWT>=2.13.0", + "cryptography>=44.0.0", "protobuf>=4.25.0", "protovalidate>=0.13.0", "grpcio>=1.60.0", diff --git a/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py b/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py index f45f09e7..3a2b0656 100644 --- a/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py +++ b/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py @@ -10,7 +10,7 @@ ATTR_USER_ID, ) from sap_cloud_sdk.core.telemetry.middleware.base import TelemetryMiddleware -from sap_cloud_sdk.ias import TokenVerifier, VerifiedIASClaims # noqa: F401 +from sap_cloud_sdk.ias import IASConfigError, IASVerifier, TokenVerifier, VerifiedIASClaims # noqa: F401 try: from starlette.middleware.base import BaseHTTPMiddleware @@ -47,18 +47,20 @@ async def dispatch(self, request: Request, call_next: Any) -> Response: class StarletteIASTelemetryMiddleware(TelemetryMiddleware): """Starlette/FastAPI middleware that extracts verified IAS JWT claims as telemetry attributes. - Reads the ``Authorization: Bearer `` header on each request, passes it through - the provided ``token_verifier``, and exposes the following as span attributes on success: + Reads the ``Authorization: Bearer `` header on each request, verifies it using + a :class:`~sap_cloud_sdk.ias.IASVerifier`, and exposes the following as span attributes + on success: - ``sap.tenancy.tenant_id`` from the ``sap_gtid`` claim - ``user.id`` from the ``user_uuid`` claim The ``x-sap-origin`` header (trigger type, not JWT identity) is always stamped when present, regardless of token verification outcome. - If ``token_verifier`` is ``None`` (the default), **no identity attributes are stamped** - and a warning is logged once at construction. This is a safe default — the app runs - normally but ``sap.tenancy.tenant_id`` and ``user.id`` will be absent from spans until - a verifier is supplied. See the IAS user guide for how to implement a verifier. + **Auto-configuration (recommended):** when no ``token_verifier`` is supplied, the + middleware automatically creates an :class:`~sap_cloud_sdk.ias.IASVerifier` from the + SAP BTP Identity service binding (``VCAP_SERVICES`` on CF, or ``IAS_URL`` env var on + Kubernetes). If the binding is not found, identity attributes are disabled and a + WARNING is logged — the app still starts normally. If the verifier raises for any reason (bad signature, wrong issuer, expired token, unknown algorithm, etc.), the identity attributes are silently omitted and the request @@ -69,39 +71,30 @@ class StarletteIASTelemetryMiddleware(TelemetryMiddleware): Args: app: The Starlette/FastAPI application instance. - token_verifier: A callable that receives the raw ``Authorization`` header value - and returns a :class:`~sap_cloud_sdk.ias.VerifiedIASClaims` on success, or - raises on any invalid token. If ``None``, identity attributes are disabled. + token_verifier: Optional. A callable that receives the raw ``Authorization`` header + value and returns :class:`~sap_cloud_sdk.ias.VerifiedIASClaims` on success, or + raises on any invalid token. When ``None`` (default), an + :class:`~sap_cloud_sdk.ias.IASVerifier` is auto-configured from the environment. Usage:: from starlette.applications import Starlette from sap_cloud_sdk.core.telemetry import auto_instrument from sap_cloud_sdk.core.telemetry.middleware import StarletteIASTelemetryMiddleware - from sap_cloud_sdk.ias import VerifiedIASClaims, parse_token - - def my_verifier(authorization: str) -> VerifiedIASClaims: - # Your platform (e.g. Kyma Istio) already verified the JWT. - # See the IAS user guide for a full JWKS-based verifier example. - return VerifiedIASClaims(claims=parse_token(authorization)) app = Starlette(...) - auto_instrument(middlewares=[StarletteIASTelemetryMiddleware(app=app, token_verifier=my_verifier)]) + # Auto-configures from IAS service binding — no extra config needed + auto_instrument(middlewares=[StarletteIASTelemetryMiddleware(app=app)]) """ def __init__(self, app: Any, token_verifier: Optional[TokenVerifier] = None) -> None: self.app = app + if token_verifier is None: + token_verifier = _auto_configure_verifier() self._token_verifier = token_verifier self._attrs_var: ContextVar[Dict[str, Any]] = ContextVar( f"ias_attrs_{id(self)}", default={} ) - if token_verifier is None: - logger.warning( - "StarletteIASTelemetryMiddleware: no token_verifier supplied — " - "sap.tenancy.tenant_id and user.id will NOT be stamped on spans. " - "Supply a token_verifier to enable verified IAS identity attributes. " - "See the IAS user guide for a JWKS-based verifier example." - ) def register(self) -> None: """Register the IAS JWT middleware with ``self.app``.""" @@ -154,3 +147,20 @@ def _extract_ias_attrs( if claims.user_uuid: attrs[ATTR_USER_ID] = claims.user_uuid return attrs + + +def _auto_configure_verifier() -> Optional[TokenVerifier]: + """Try to build an IASVerifier from the environment; warn and return None if not possible.""" + try: + verifier = IASVerifier.from_env() + logger.debug("StarletteIASTelemetryMiddleware: auto-configured IASVerifier from environment") + return verifier + except IASConfigError as exc: + logger.warning( + "StarletteIASTelemetryMiddleware: IAS service binding not found — " + "sap.tenancy.tenant_id and user.id will NOT be stamped on spans. " + "Bind an SAP Identity service instance or set IAS_URL to enable identity attributes. " + "Details: %s", + exc, + ) + return None diff --git a/src/sap_cloud_sdk/ias/__init__.py b/src/sap_cloud_sdk/ias/__init__.py index 3a4dea69..3c42e683 100644 --- a/src/sap_cloud_sdk/ias/__init__.py +++ b/src/sap_cloud_sdk/ias/__init__.py @@ -1,6 +1,6 @@ """SAP Cloud SDK for Python - IAS module -Utilities for parsing SAP Identity Authentication Service (IAS) JWT tokens. +Utilities for parsing and verifying SAP Identity Authentication Service (IAS) JWT tokens. Usage: from sap_cloud_sdk.ias import parse_token, IASClaims @@ -13,11 +13,14 @@ """ from sap_cloud_sdk.ias._token import IASClaims, TokenVerifier, VerifiedIASClaims, parse_token +from sap_cloud_sdk.ias._verifier import IASConfigError, IASVerifier from sap_cloud_sdk.ias.exceptions import IASTokenError __all__ = [ "IASClaims", + "IASConfigError", "IASTokenError", + "IASVerifier", "TokenVerifier", "VerifiedIASClaims", "parse_token", diff --git a/src/sap_cloud_sdk/ias/_verifier.py b/src/sap_cloud_sdk/ias/_verifier.py new file mode 100644 index 00000000..9b5219a9 --- /dev/null +++ b/src/sap_cloud_sdk/ias/_verifier.py @@ -0,0 +1,161 @@ +"""Built-in JWKS-backed IAS JWT verifier.""" + +import json +import logging +import os +from typing import Optional + +import jwt +from jwt import PyJWKClient + +from sap_cloud_sdk.ias._token import VerifiedIASClaims, parse_token +from sap_cloud_sdk.ias.exceptions import IASTokenError + +logger = logging.getLogger(__name__) + +_ENV_IAS_URL = "IAS_URL" +_ENV_IAS_CLIENT_ID = "IAS_CLIENT_ID" + + +class IASConfigError(Exception): + """Raised when IAS configuration cannot be resolved from the environment.""" + + +class IASVerifier: + """JWKS-backed IAS JWT verifier. + + Verifies the JWT signature using the IAS JWKS endpoint and validates + issuer, expiration, and not-before constraints. Optionally validates + the audience (``aud`` claim) against the application's client ID. + + Designed to be instantiated once at application startup and shared across + requests. ``PyJWKClient`` caches keys internally and handles key rotation + transparently. + + Args: + ias_url: IAS tenant base URL, e.g. ``https://.accounts.ondemand.com``. + The JWKS endpoint is derived as ``{ias_url}/oauth2/certs``. + client_id: Expected ``aud`` claim (the application's client ID in IAS). + When provided, tokens issued for other applications are rejected. + When ``None``, audience validation is skipped. + + Usage:: + + from sap_cloud_sdk.ias import IASVerifier + + # Auto-configure from the IAS service binding (recommended) + verifier = IASVerifier.from_env() + + # Or configure explicitly + verifier = IASVerifier( + ias_url="https://mytenant.accounts.ondemand.com", + client_id="my-app-client-id", + ) + + # Use as a TokenVerifier + verified = verifier("Bearer ") + print(verified.claims.sap_gtid) + """ + + def __init__(self, ias_url: str, client_id: Optional[str] = None) -> None: + self._ias_url = ias_url.rstrip("/") + self._client_id = client_id + jwks_url = f"{self._ias_url}/oauth2/certs" + self._jwk_client = PyJWKClient(jwks_url, cache_keys=True) + logger.debug( + "IASVerifier initialised (jwks=%s, client_id=%s)", + jwks_url, + client_id or "", + ) + + @classmethod + def from_env(cls) -> "IASVerifier": + """Auto-configure from the SAP BTP Identity service binding. + + Lookup order: + + 1. ``VCAP_SERVICES`` (Cloud Foundry) — + ``identity[0].credentials.{url, clientid}`` + 2. ``IAS_URL`` + ``IAS_CLIENT_ID`` environment variables (Kubernetes / manual) + + Returns: + A configured :class:`IASVerifier` instance. + + Raises: + IASConfigError: when no IAS configuration can be resolved. + Bind an SAP Identity service instance or set ``IAS_URL`` + (and optionally ``IAS_CLIENT_ID``). + """ + # Cloud Foundry: VCAP_SERVICES + vcap_raw = os.getenv("VCAP_SERVICES") + if vcap_raw: + try: + vcap = json.loads(vcap_raw) + for svc_name in ("identity", "xsuaa"): + bindings = vcap.get(svc_name, []) + if bindings: + creds = bindings[0].get("credentials", {}) + url = creds.get("url") or creds.get("issuer") + client_id = creds.get("clientid") + if url: + logger.debug( + "IASVerifier.from_env: configured from VCAP_SERVICES[%s]", + svc_name, + ) + return cls(ias_url=url, client_id=client_id or None) + except (json.JSONDecodeError, KeyError, IndexError, TypeError) as exc: + logger.debug("IASVerifier.from_env: VCAP_SERVICES parse error: %s", exc) + + # Kubernetes / manual: explicit env vars + ias_url = os.getenv(_ENV_IAS_URL) + if ias_url: + client_id = os.getenv(_ENV_IAS_CLIENT_ID) or None + logger.debug("IASVerifier.from_env: configured from env vars") + return cls(ias_url=ias_url, client_id=client_id) + + raise IASConfigError( + f"Cannot auto-configure IASVerifier: no IAS service binding found. " + f"Options:\n" + f" • Bind an SAP Identity service instance (sets VCAP_SERVICES on CF or " + f"a Kubernetes secret)\n" + f" • Set {_ENV_IAS_URL} (and optionally {_ENV_IAS_CLIENT_ID}) manually\n" + f"See the IAS user guide for details." + ) + + def __call__(self, authorization: str) -> VerifiedIASClaims: + """Verify the token and return its claims. + + Args: + authorization: Raw ``Authorization`` header value. + Accepts ``"Bearer "`` or a bare token string. + + Returns: + :class:`~sap_cloud_sdk.ias.VerifiedIASClaims` on success. + + Raises: + IASTokenError: if the token fails any validation check (bad + signature, wrong issuer, wrong audience, expired, not-yet-valid, + unsupported algorithm, unknown key, or malformed structure). + """ + raw = authorization.removeprefix("Bearer ").removeprefix("bearer ").strip() + try: + signing_key = self._jwk_client.get_signing_key_from_jwt(raw) + + options: dict = {"require": ["exp", "iss"]} + decode_kwargs: dict = { + "algorithms": ["RS256", "ES256"], # pin asymmetric algs only; reject none/HS* + "issuer": self._ias_url, + "options": options, + } + if self._client_id: + decode_kwargs["audience"] = self._client_id + options["require"].append("aud") + else: + options["verify_aud"] = False + + jwt.decode(raw, signing_key.key, **decode_kwargs) + + except jwt.exceptions.PyJWTError as exc: + raise IASTokenError(f"IAS JWT verification failed: {exc}") from exc + + return VerifiedIASClaims(claims=parse_token(raw)) diff --git a/tests/core/unit/telemetry/middleware/test_starlette_a2a.py b/tests/core/unit/telemetry/middleware/test_starlette_a2a.py index 62e18b35..ed9f9ac0 100644 --- a/tests/core/unit/telemetry/middleware/test_starlette_a2a.py +++ b/tests/core/unit/telemetry/middleware/test_starlette_a2a.py @@ -2,14 +2,15 @@ import logging import pytest -from unittest.mock import MagicMock, AsyncMock +from unittest.mock import MagicMock, AsyncMock, patch from sap_cloud_sdk.core.telemetry.constants import ATTR_SAP_TRIGGER_TYPE, ATTR_SAP_TENANT_ID, ATTR_USER_ID from sap_cloud_sdk.core.telemetry.middleware.starlette_a2a import ( StarletteIASTelemetryMiddleware, + _auto_configure_verifier, _extract_ias_attrs, ) -from sap_cloud_sdk.ias import IASClaims, IASTokenError, VerifiedIASClaims +from sap_cloud_sdk.ias import IASClaims, IASConfigError, IASTokenError, IASVerifier, VerifiedIASClaims # --------------------------------------------------------------------------- @@ -40,6 +41,39 @@ def verify(token: str) -> VerifiedIASClaims: return verify +# --------------------------------------------------------------------------- +# _auto_configure_verifier +# --------------------------------------------------------------------------- + +class TestAutoConfigureVerifier: + def test_returns_verifier_when_env_configured(self): + mock_verifier = MagicMock(spec=IASVerifier) + with patch("sap_cloud_sdk.core.telemetry.middleware.starlette_a2a.IASVerifier") as MockV: + MockV.from_env.return_value = mock_verifier + result = _auto_configure_verifier() + assert result is mock_verifier + + def test_returns_none_when_config_missing(self): + with patch("sap_cloud_sdk.core.telemetry.middleware.starlette_a2a.IASVerifier") as MockV: + MockV.from_env.side_effect = IASConfigError("no binding found") + result = _auto_configure_verifier() + assert result is None + + def test_logs_warning_when_config_missing(self, caplog): + with patch("sap_cloud_sdk.core.telemetry.middleware.starlette_a2a.IASVerifier") as MockV: + MockV.from_env.side_effect = IASConfigError("no IAS binding") + with caplog.at_level(logging.WARNING): + _auto_configure_verifier() + assert any("NOT be stamped" in r.message for r in caplog.records) + + def test_no_warning_when_configured(self, caplog): + with patch("sap_cloud_sdk.core.telemetry.middleware.starlette_a2a.IASVerifier") as MockV: + MockV.from_env.return_value = MagicMock(spec=IASVerifier) + with caplog.at_level(logging.WARNING): + _auto_configure_verifier() + assert not any("NOT be stamped" in r.message for r in caplog.records) + + # --------------------------------------------------------------------------- # StarletteIASTelemetryMiddleware construction # --------------------------------------------------------------------------- @@ -73,15 +107,38 @@ def test_two_instances_do_not_interfere(self): mw1._attrs_var.reset(t1) mw2._attrs_var.reset(t2) - def test_no_verifier_logs_warning(self, caplog): - with caplog.at_level(logging.WARNING): - StarletteIASTelemetryMiddleware(app=MagicMock(), token_verifier=None) - assert any("token_verifier" in r.message for r in caplog.records) - - def test_verifier_provided_no_warning(self, caplog): + def test_auto_configure_called_when_no_verifier_given(self): + mock_verifier = MagicMock(spec=IASVerifier) + with patch("sap_cloud_sdk.core.telemetry.middleware.starlette_a2a.IASVerifier") as MockV: + MockV.from_env.return_value = mock_verifier + mw = StarletteIASTelemetryMiddleware(app=MagicMock()) + assert mw._token_verifier is mock_verifier + + def test_auto_configure_failure_sets_none_verifier(self): + with patch("sap_cloud_sdk.core.telemetry.middleware.starlette_a2a.IASVerifier") as MockV: + MockV.from_env.side_effect = IASConfigError("no binding") + mw = StarletteIASTelemetryMiddleware(app=MagicMock()) + assert mw._token_verifier is None + + def test_no_verifier_and_no_env_logs_warning(self, caplog): + with patch("sap_cloud_sdk.core.telemetry.middleware.starlette_a2a.IASVerifier") as MockV: + MockV.from_env.side_effect = IASConfigError("no binding") + with caplog.at_level(logging.WARNING): + StarletteIASTelemetryMiddleware(app=MagicMock()) + assert any("NOT be stamped" in r.message for r in caplog.records) + + def test_explicit_verifier_bypasses_auto_configure(self): + """When token_verifier is explicitly passed, IASVerifier.from_env must not be called.""" + with patch("sap_cloud_sdk.core.telemetry.middleware.starlette_a2a.IASVerifier") as MockV: + explicit = _passing_verifier() + mw = StarletteIASTelemetryMiddleware(app=MagicMock(), token_verifier=explicit) + MockV.from_env.assert_not_called() + assert mw._token_verifier is explicit + + def test_explicit_verifier_no_warning(self, caplog): with caplog.at_level(logging.WARNING): StarletteIASTelemetryMiddleware(app=MagicMock(), token_verifier=_passing_verifier()) - assert not any("token_verifier" in r.message for r in caplog.records) + assert not any("NOT be stamped" in r.message for r in caplog.records) # --------------------------------------------------------------------------- diff --git a/tests/ias/unit/test_verifier.py b/tests/ias/unit/test_verifier.py new file mode 100644 index 00000000..be329cfa --- /dev/null +++ b/tests/ias/unit/test_verifier.py @@ -0,0 +1,285 @@ +"""Unit tests for sap_cloud_sdk.ias IASVerifier.""" + +import json +import pytest +from unittest.mock import MagicMock, patch + +from sap_cloud_sdk.ias import IASClaims, IASConfigError, IASTokenError, IASVerifier, VerifiedIASClaims + + +# --------------------------------------------------------------------------- +# IASVerifier.from_env — configuration resolution +# --------------------------------------------------------------------------- + +class TestIASVerifierFromEnv: + def test_cf_vcap_services_identity_binding(self, monkeypatch): + vcap = {"identity": [{"credentials": {"url": "https://ias.example.com", "clientid": "my-app"}}]} + monkeypatch.setenv("VCAP_SERVICES", json.dumps(vcap)) + monkeypatch.delenv("IAS_URL", raising=False) + + with patch("sap_cloud_sdk.ias._verifier.PyJWKClient"): + v = IASVerifier.from_env() + + assert v._ias_url == "https://ias.example.com" + assert v._client_id == "my-app" + + def test_cf_vcap_services_strips_trailing_slash(self, monkeypatch): + vcap = {"identity": [{"credentials": {"url": "https://ias.example.com/", "clientid": "cid"}}]} + monkeypatch.setenv("VCAP_SERVICES", json.dumps(vcap)) + monkeypatch.delenv("IAS_URL", raising=False) + + with patch("sap_cloud_sdk.ias._verifier.PyJWKClient"): + v = IASVerifier.from_env() + + assert v._ias_url == "https://ias.example.com" + + def test_cf_vcap_services_no_client_id(self, monkeypatch): + vcap = {"identity": [{"credentials": {"url": "https://ias.example.com"}}]} + monkeypatch.setenv("VCAP_SERVICES", json.dumps(vcap)) + monkeypatch.delenv("IAS_URL", raising=False) + + with patch("sap_cloud_sdk.ias._verifier.PyJWKClient"): + v = IASVerifier.from_env() + + assert v._client_id is None + + def test_cf_vcap_services_xsuaa_fallback(self, monkeypatch): + vcap = {"xsuaa": [{"credentials": {"url": "https://xsuaa.example.com", "clientid": "xc"}}]} + monkeypatch.setenv("VCAP_SERVICES", json.dumps(vcap)) + monkeypatch.delenv("IAS_URL", raising=False) + + with patch("sap_cloud_sdk.ias._verifier.PyJWKClient"): + v = IASVerifier.from_env() + + assert v._ias_url == "https://xsuaa.example.com" + + def test_k8s_env_vars(self, monkeypatch): + monkeypatch.delenv("VCAP_SERVICES", raising=False) + monkeypatch.setenv("IAS_URL", "https://k8s-ias.example.com") + monkeypatch.setenv("IAS_CLIENT_ID", "k8s-client") + + with patch("sap_cloud_sdk.ias._verifier.PyJWKClient"): + v = IASVerifier.from_env() + + assert v._ias_url == "https://k8s-ias.example.com" + assert v._client_id == "k8s-client" + + def test_k8s_env_vars_no_client_id(self, monkeypatch): + monkeypatch.delenv("VCAP_SERVICES", raising=False) + monkeypatch.setenv("IAS_URL", "https://k8s-ias.example.com") + monkeypatch.delenv("IAS_CLIENT_ID", raising=False) + + with patch("sap_cloud_sdk.ias._verifier.PyJWKClient"): + v = IASVerifier.from_env() + + assert v._client_id is None + + def test_raises_ias_config_error_when_nothing_configured(self, monkeypatch): + monkeypatch.delenv("VCAP_SERVICES", raising=False) + monkeypatch.delenv("IAS_URL", raising=False) + monkeypatch.delenv("IAS_CLIENT_ID", raising=False) + + with pytest.raises(IASConfigError): + IASVerifier.from_env() + + def test_raises_ias_config_error_with_malformed_vcap(self, monkeypatch): + monkeypatch.setenv("VCAP_SERVICES", "not-valid-json") + monkeypatch.delenv("IAS_URL", raising=False) + + with pytest.raises(IASConfigError): + IASVerifier.from_env() + + def test_raises_ias_config_error_vcap_empty_bindings(self, monkeypatch): + vcap = {"identity": [], "xsuaa": []} + monkeypatch.setenv("VCAP_SERVICES", json.dumps(vcap)) + monkeypatch.delenv("IAS_URL", raising=False) + + with pytest.raises(IASConfigError): + IASVerifier.from_env() + + def test_vcap_identity_takes_precedence_over_env_var(self, monkeypatch): + vcap = {"identity": [{"credentials": {"url": "https://vcap-ias.example.com", "clientid": "vc"}}]} + monkeypatch.setenv("VCAP_SERVICES", json.dumps(vcap)) + monkeypatch.setenv("IAS_URL", "https://env-ias.example.com") + + with patch("sap_cloud_sdk.ias._verifier.PyJWKClient"): + v = IASVerifier.from_env() + + assert v._ias_url == "https://vcap-ias.example.com" + + +# --------------------------------------------------------------------------- +# IASVerifier.__call__ — token verification +# --------------------------------------------------------------------------- + +class TestIASVerifierCall: + def _make_verifier(self, ias_url="https://ias.example.com", client_id=None): + with patch("sap_cloud_sdk.ias._verifier.PyJWKClient"): + v = IASVerifier(ias_url=ias_url, client_id=client_id) + return v + + def _mock_jwk_key(self, verifier): + mock_key = MagicMock() + mock_key.key = "mock-signing-key" + verifier._jwk_client.get_signing_key_from_jwt.return_value = mock_key + return mock_key + + def test_returns_verified_ias_claims_on_success(self): + import jwt as pyjwt + + verifier = self._make_verifier() + raw_token = pyjwt.encode( + {"sap_gtid": "tenant-1", "user_uuid": "user-1"}, + key="secret", + algorithm="HS256", + ) + self._mock_jwk_key(verifier) + + with patch("sap_cloud_sdk.ias._verifier.jwt.decode") as mock_decode: + mock_decode.return_value = {"sap_gtid": "tenant-1", "user_uuid": "user-1", "iss": "https://ias.example.com"} + result = verifier(f"Bearer {raw_token}") + + assert isinstance(result, VerifiedIASClaims) + assert result.claims.sap_gtid == "tenant-1" + assert result.claims.user_uuid == "user-1" + + def test_strips_bearer_prefix(self): + import jwt as pyjwt + + verifier = self._make_verifier() + raw_token = pyjwt.encode({"sub": "x"}, key="s", algorithm="HS256") + self._mock_jwk_key(verifier) + + with patch("sap_cloud_sdk.ias._verifier.jwt.decode") as mock_decode: + mock_decode.return_value = {} + verifier(f"Bearer {raw_token}") + + call_args = verifier._jwk_client.get_signing_key_from_jwt.call_args[0][0] + assert call_args == raw_token + + def test_strips_lowercase_bearer_prefix(self): + import jwt as pyjwt + + verifier = self._make_verifier() + raw_token = pyjwt.encode({"sub": "x"}, key="s", algorithm="HS256") + self._mock_jwk_key(verifier) + + with patch("sap_cloud_sdk.ias._verifier.jwt.decode") as mock_decode: + mock_decode.return_value = {} + verifier(f"bearer {raw_token}") + + call_args = verifier._jwk_client.get_signing_key_from_jwt.call_args[0][0] + assert call_args == raw_token + + def test_raises_ias_token_error_on_bad_signature(self): + import jwt.exceptions + + verifier = self._make_verifier() + verifier._jwk_client.get_signing_key_from_jwt.side_effect = jwt.exceptions.InvalidSignatureError("bad sig") + + with pytest.raises(IASTokenError, match="IAS JWT verification failed"): + verifier("Bearer bad.token.here") + + def test_raises_ias_token_error_on_expired_token(self): + import jwt.exceptions + + verifier = self._make_verifier() + verifier._jwk_client.get_signing_key_from_jwt.side_effect = jwt.exceptions.ExpiredSignatureError("expired") + + with pytest.raises(IASTokenError): + verifier("Bearer expired.token") + + def test_raises_ias_token_error_on_wrong_issuer(self): + import jwt.exceptions + + verifier = self._make_verifier() + self._mock_jwk_key(verifier) + + with patch("sap_cloud_sdk.ias._verifier.jwt.decode") as mock_decode: + mock_decode.side_effect = jwt.exceptions.InvalidIssuerError("wrong issuer") + with pytest.raises(IASTokenError): + verifier("Bearer tok") + + def test_raises_ias_token_error_on_wrong_audience(self): + import jwt.exceptions + + verifier = self._make_verifier(client_id="expected-client") + self._mock_jwk_key(verifier) + + with patch("sap_cloud_sdk.ias._verifier.jwt.decode") as mock_decode: + mock_decode.side_effect = jwt.exceptions.InvalidAudienceError("wrong aud") + with pytest.raises(IASTokenError): + verifier("Bearer tok") + + def test_raises_ias_token_error_on_decode_error(self): + import jwt.exceptions + + verifier = self._make_verifier() + self._mock_jwk_key(verifier) + + with patch("sap_cloud_sdk.ias._verifier.jwt.decode") as mock_decode: + mock_decode.side_effect = jwt.exceptions.DecodeError("malformed") + with pytest.raises(IASTokenError): + verifier("Bearer malformed.token") + + def test_includes_audience_when_client_id_set(self): + import jwt as pyjwt + + verifier = self._make_verifier(client_id="my-client") + raw_token = pyjwt.encode({"sub": "x"}, key="s", algorithm="HS256") + self._mock_jwk_key(verifier) + + with patch("sap_cloud_sdk.ias._verifier.jwt.decode") as mock_decode: + mock_decode.return_value = {} + verifier(f"Bearer {raw_token}") + + # call_args_list[0] is the verification call; [1] is parse_token's call + _, kwargs = mock_decode.call_args_list[0] + assert kwargs.get("audience") == "my-client" + assert "aud" in kwargs["options"]["require"] + + def test_skips_audience_when_no_client_id(self): + import jwt as pyjwt + + verifier = self._make_verifier(client_id=None) + raw_token = pyjwt.encode({"sub": "x"}, key="s", algorithm="HS256") + self._mock_jwk_key(verifier) + + with patch("sap_cloud_sdk.ias._verifier.jwt.decode") as mock_decode: + mock_decode.return_value = {} + verifier(f"Bearer {raw_token}") + + _, kwargs = mock_decode.call_args_list[0] + assert "audience" not in kwargs + assert kwargs["options"].get("verify_aud") is False + + def test_pins_asymmetric_algorithms_only(self): + import jwt as pyjwt + + verifier = self._make_verifier() + raw_token = pyjwt.encode({"sub": "x"}, key="s", algorithm="HS256") + self._mock_jwk_key(verifier) + + with patch("sap_cloud_sdk.ias._verifier.jwt.decode") as mock_decode: + mock_decode.return_value = {} + verifier(f"Bearer {raw_token}") + + _, kwargs = mock_decode.call_args_list[0] + assert kwargs["algorithms"] == ["RS256", "ES256"] + assert "none" not in kwargs["algorithms"] + assert "HS256" not in kwargs["algorithms"] + + def test_requires_exp_and_iss_claims(self): + import jwt as pyjwt + + verifier = self._make_verifier() + raw_token = pyjwt.encode({"sub": "x"}, key="s", algorithm="HS256") + self._mock_jwk_key(verifier) + + with patch("sap_cloud_sdk.ias._verifier.jwt.decode") as mock_decode: + mock_decode.return_value = {} + verifier(f"Bearer {raw_token}") + + _, kwargs = mock_decode.call_args_list[0] + assert "exp" in kwargs["options"]["require"] + assert "iss" in kwargs["options"]["require"] diff --git a/uv.lock b/uv.lock index 3a1eb688..c09ac7f4 100644 --- a/uv.lock +++ b/uv.lock @@ -2597,20 +2597,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/a3/f2c38b8cd448ee536870bc7771d863ec57232446c850124adcd08bc8e3c0/opentelemetry_instrumentation_writer-0.52.3-py3-none-any.whl", hash = "sha256:c5155413d6f4e6ae7b842d6b1b2df21a244969c669e3ffb78a7ef7c57afb1da5", size = 11516, upload-time = "2026-02-10T14:54:43.197Z" }, ] -[[package]] -name = "opentelemetry-processor-baggage" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/0d/4afee20490ef53a449b1781b0671d84858742a2ccfb01c08de398a5d1ccd/opentelemetry_processor_baggage-0.61b0.tar.gz", hash = "sha256:4d1d2a624e3aa9a8b6c6d1f560ba2951f97acf875f57502a274c5078043a69d5", size = 7573, upload-time = "2026-03-04T14:20:54.941Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/24/0ef2cf49e6ac9b2b422400abbf528230a409c9e174572f2d13e2dff7ec7c/opentelemetry_processor_baggage-0.61b0-py3-none-any.whl", hash = "sha256:f6b5937e93bda8f380d8f5f667355c7d127e9296b38dfacf39fd328ab410262c", size = 8881, upload-time = "2026-03-04T14:20:05.25Z" }, -] - [[package]] name = "opentelemetry-proto" version = "1.42.1" @@ -3713,9 +3699,10 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.35.1" +version = "0.35.0" source = { editable = "." } dependencies = [ + { name = "cryptography" }, { name = "grpcio" }, { name = "hatchling" }, { name = "httpx" }, @@ -3725,7 +3712,6 @@ dependencies = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-instrumentation-langchain" }, - { name = "opentelemetry-processor-baggage" }, { name = "opentelemetry-sdk" }, { name = "protobuf" }, { name = "protovalidate" }, @@ -3744,6 +3730,9 @@ extensibility = [ langchain = [ { name = "langchain-core" }, ] +langgraph = [ + { name = "langgraph" }, +] starlette = [ { name = "starlette" }, ] @@ -3772,17 +3761,18 @@ dev = [ [package.metadata] requires-dist = [ { name = "a2a-sdk", marker = "extra == 'extensibility'", specifier = ">=0.2.0" }, + { name = "cryptography", specifier = ">=44.0.0" }, { name = "grpcio", specifier = ">=1.60.0" }, { name = "hatchling", specifier = "~=1.27.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "langchain-core", marker = "extra == 'langchain'", specifier = ">=1.2.7" }, + { name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, { name = "mcp", specifier = ">=1.1.0" }, { name = "minio", specifier = "~=7.2.16" }, { name = "opentelemetry-api", specifier = ">=1.42.1" }, { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = "~=1.42.1" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.42.1" }, { name = "opentelemetry-instrumentation-langchain", specifier = ">=0.61.0" }, - { name = "opentelemetry-processor-baggage", specifier = "~=0.61b0" }, { name = "opentelemetry-sdk", specifier = ">=1.42.1" }, { name = "protobuf", specifier = ">=4.25.0" }, { name = "protovalidate", specifier = ">=0.13.0" }, @@ -3794,7 +3784,7 @@ requires-dist = [ { name = "starlette", marker = "extra == 'starlette'", specifier = ">=0.40.0" }, { name = "traceloop-sdk", specifier = "~=0.61.0" }, ] -provides-extras = ["extensibility", "starlette", "langchain"] +provides-extras = ["extensibility", "starlette", "langchain", "langgraph"] [package.metadata.requires-dev] dev = [