"""Verified caller identity for approval-engine's HTTP boundary. The engine enforces scopes issued by the identity owner; it does not decide who ought to hold them. Production uses KeyCape's RS256/JWKS contract. Tests use an explicit static verifier so no unverified claim can accidentally become a production fallback. """ from __future__ import annotations import hashlib from dataclasses import dataclass from typing import Any, Mapping, Protocol import jwt from .errors import Forbidden, Unauthenticated @dataclass(frozen=True) class Identity: subject: str issuer: str audiences: tuple[str, ...] principal_type: str tenant: str roles: frozenset[str] scopes: frozenset[str] assurance: dict[str, Any] evidence_ref: str def require(self, scope: str) -> "Identity": if scope not in self.scopes: raise Forbidden(f"caller lacks required scope {scope!r}") return self class Authenticator(Protocol): def authenticate(self, authorization: str | None) -> Identity: ... def _bearer(authorization: str | None) -> str: value = str(authorization or "") if not value.startswith("Bearer "): raise Unauthenticated("bearer token is required") token = value[7:].strip() if not token or any(ch.isspace() for ch in token): raise Unauthenticated("bearer token is invalid") return token def _texts(value: object, name: str, *, allow_empty: bool = False) -> tuple[str, ...]: if isinstance(value, str): items = tuple(item for item in value.split() if item) elif isinstance(value, (list, tuple)): items = tuple(item for item in value if isinstance(item, str) and item) if len(items) != len(value): raise Unauthenticated(f"token claim {name!r} is invalid") else: raise Unauthenticated(f"token claim {name!r} is invalid") if not items and not allow_empty: raise Unauthenticated(f"token claim {name!r} is empty") return items def identity_from_claims(claims: Mapping[str, Any], token: str) -> Identity: try: subject = str(claims["sub"]) issuer = str(claims["iss"]) principal_type = str(claims["principal_type"]) tenant = str(claims["tenant"]) assurance = claims["assurance"] except (KeyError, TypeError) as exc: raise Unauthenticated("token is missing required identity claims") from exc if not subject or not issuer or principal_type not in {"human", "service", "agent"}: raise Unauthenticated("token identity claims are invalid") if not tenant or not isinstance(assurance, dict): raise Unauthenticated("token tenant or assurance claim is invalid") audiences = _texts(claims.get("aud"), "aud") roles = frozenset(_texts(claims.get("roles"), "roles", allow_empty=True)) scopes = frozenset(_texts(claims.get("scope", claims.get("scp")), "scope")) fingerprint = hashlib.sha256(token.encode("utf-8")).hexdigest() return Identity( subject=subject, issuer=issuer, audiences=audiences, principal_type=principal_type, tenant=tenant, roles=roles, scopes=scopes, assurance=dict(assurance), evidence_ref=f"jwt-sha256:{fingerprint}", ) class JWTAuthenticator: """Verify KeyCape JWT signature, issuer, audience, time, and profile claims.""" def __init__( self, *, issuer: str, audience: str, jwks_url: str, leeway_seconds: int = 30, timeout_seconds: int = 3, jwks_client: Any | None = None, ) -> None: if not issuer or not audience or not jwks_url: raise ValueError("issuer, audience, and jwks_url are required") self.issuer = issuer self.audience = audience self.leeway_seconds = leeway_seconds self.jwks_client = jwks_client or jwt.PyJWKClient( jwks_url, cache_keys=True, cache_jwk_set=True, lifespan=300, timeout=timeout_seconds, ) def authenticate(self, authorization: str | None) -> Identity: token = _bearer(authorization) try: signing_key = self.jwks_client.get_signing_key_from_jwt(token) claims = jwt.decode( token, signing_key.key, algorithms=["RS256"], issuer=self.issuer, audience=self.audience, leeway=self.leeway_seconds, options={ "require": [ "iss", "sub", "aud", "exp", "iat", "tenant", "principal_type", "roles", "scope", "assurance", ] }, ) except Exception as exc: raise Unauthenticated("token verification failed") from exc return identity_from_claims(claims, token) class StaticTokenAuthenticator: """Explicit test/development verifier; never selected implicitly.""" def __init__(self, identities: Mapping[str, Identity]) -> None: self.identities = dict(identities) def authenticate(self, authorization: str | None) -> Identity: token = _bearer(authorization) identity = self.identities.get(token) if identity is None: raise Unauthenticated("token verification failed") return identity class DenyAllAuthenticator: def authenticate(self, authorization: str | None) -> Identity: raise Unauthenticated("caller authentication is not configured")