Source code for litestar_security.providers.jwt._capabilities

"""Claim construction and normalization for stateless capability JWTs."""

from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from math import isfinite
from secrets import token_urlsafe
from typing import cast

from litestar_security.authentication import InvalidCredentials
from litestar_security.providers._internal import JSONValue
from litestar_security.providers.jwt._claims import JWTAlgorithm
from litestar_security.providers.jwt._internal import (
    aware_utc,
    freeze_json,
    is_strict_identifier,
    raise_value,
    strict_identifier_value,
)

__all__ = ("CAPABILITY_TOKEN_TYPE", "VerifiedCapability")

CAPABILITY_TOKEN_TYPE = "capability+jwt"  # noqa: S105 - JOSE typ identifier, not a credential
_CAPABILITY_CLOCK_SKEW = timedelta(seconds=30)
_CAPABILITY_MAXIMUM_LIFETIME = timedelta(hours=24)
_MAXIMUM_APPLICATION_CLAIM_DEPTH = 32
_RESERVED_CAPABILITY_CLAIMS = frozenset({"iss", "sub", "aud", "exp", "iat", "nbf", "purpose", "jti"})
_REQUIRED_CAPABILITY_CLAIMS = _RESERVED_CAPABILITY_CLAIMS.difference({"nbf"})
_FORBIDDEN_JOSE_HEADERS = frozenset({"b64", "crit", "jku", "jwk", "x5c", "x5t", "x5t#S256", "x5u"})
_SUPPORTED_CAPABILITY_ALGORITHMS = frozenset({"EdDSA", "ES256", "RS256", "HS256"})
_INVALID = InvalidCredentials()


[docs] @dataclass(frozen=True, slots=True) class VerifiedCapability: """Verified application capability claims without the compact credential. Args: purpose: The exact application-defined capability purpose. subject: The principal the capability represents. audience: The exact service or resource allowed to accept the capability. issued_at: The timezone-aware timestamp at which the capability was issued. expires_at: The timezone-aware timestamp at which the capability expires. token_id: The unique capability identifier used for optional application-level consumption. claims: The immutable application claims with reserved credential claims removed. Returns: A frozen capability projection that contains no compact credential. Raises: Never directly raises; invalid credentials are rejected before this value is created. """ purpose: str subject: str audience: str issued_at: datetime expires_at: datetime token_id: str claims: Mapping[str, JSONValue] def __post_init__(self) -> None: """Freeze application claims at the verified boundary.""" object.__setattr__(self, "claims", cast("Mapping[str, JSONValue]", freeze_json(dict(self.claims))))
def validate_capability_header(header: Mapping[str, JSONValue]) -> tuple[JWTAlgorithm, str] | InvalidCredentials: """Validate immutable routing fields for one capability JWT. Args: header: The cryptographically untrusted JOSE header. Returns: The exact algorithm and key identifier to use for signature verification, or a sanitized rejected outcome. """ algorithm = header.get("alg") token_type = header.get("typ") key_id = header.get("kid") if ( not isinstance(algorithm, str) or algorithm not in _SUPPORTED_CAPABILITY_ALGORITHMS or algorithm == "none" or token_type != CAPABILITY_TOKEN_TYPE or not isinstance(key_id, str) or not is_strict_identifier(key_id) or _FORBIDDEN_JOSE_HEADERS.intersection(header) ): return _INVALID return cast("JWTAlgorithm", algorithm), key_id def build_capability_claims( # noqa: PLR0913 - explicit claim construction inputs remain cohesive *, issuer: str, purpose: str, subject: str, audience: str, lifetime: timedelta, claims: Mapping[str, JSONValue], now: datetime, ) -> Mapping[str, JSONValue]: """Build one bounded, single-purpose capability claim set.""" issuer = strict_identifier_value(issuer) purpose = strict_identifier_value(purpose) subject = strict_identifier_value(subject) audience = strict_identifier_value(audience) now = aware_utc(now) if lifetime <= timedelta(0) or lifetime > _CAPABILITY_MAXIMUM_LIFETIME: raise_value("Capability lifetime must be positive and no longer than 24 hours") if any(key.__class__ is not str for key in claims): raise_value("Capability application claims must use JSON object keys") if _RESERVED_CAPABILITY_CLAIMS.intersection(claims): raise_value("Capability application claims must not use reserved names") expires_at = now + lifetime issued_timestamp = int(now.timestamp()) expires_timestamp = int(expires_at.timestamp()) if expires_timestamp <= issued_timestamp: raise_value("Capability lifetime must span at least one whole second") payload = {key: _copy_json(value) for key, value in claims.items()} payload.update({ "iss": issuer, "sub": subject, "aud": audience, "exp": expires_timestamp, "iat": issued_timestamp, "purpose": purpose, "jti": strict_identifier_value(token_urlsafe(32)), }) return payload def normalize_capability_claims( # noqa: PLR0911 - preserve explicit sanitized outcomes at each security boundary payload: Mapping[str, JSONValue], *, purpose: str, audience: str, issuer: str, now: datetime ) -> VerifiedCapability | InvalidCredentials: """Normalize verified capability claims into one sanitized outcome.""" try: now = aware_utc(now) except (AttributeError, TypeError, ValueError): return _INVALID if not _REQUIRED_CAPABILITY_CLAIMS.issubset(payload): return _INVALID claim_issuer = payload.get("iss") claim_subject = payload.get("sub") claim_audience = payload.get("aud") claim_purpose = payload.get("purpose") claim_token_id = payload.get("jti") if ( not isinstance(claim_issuer, str) or not is_strict_identifier(claim_issuer) or not isinstance(claim_subject, str) or not is_strict_identifier(claim_subject) or not isinstance(claim_audience, str) or not is_strict_identifier(claim_audience) or not isinstance(claim_purpose, str) or not is_strict_identifier(claim_purpose) or not isinstance(claim_token_id, str) or not is_strict_identifier(claim_token_id) or claim_issuer != issuer or claim_audience != audience or claim_purpose != purpose ): return _INVALID issued_at = _numeric_date(payload.get("iat")) expires_at = _numeric_date(payload.get("exp")) not_before_value = payload.get("nbf") not_before = None if not_before_value is None else _numeric_date(not_before_value) if issued_at is None or expires_at is None or (not_before_value is not None and not_before is None): return _INVALID if ( issued_at > now + _CAPABILITY_CLOCK_SKEW or expires_at <= now - _CAPABILITY_CLOCK_SKEW or (not_before is not None and not_before > now + _CAPABILITY_CLOCK_SKEW) ): return _INVALID lifetime = expires_at - issued_at if ( lifetime <= timedelta(0) or lifetime > _CAPABILITY_MAXIMUM_LIFETIME or (not_before is not None and not_before >= expires_at) ): return _INVALID application_claims = {key: value for key, value in payload.items() if key not in _RESERVED_CAPABILITY_CLAIMS} return VerifiedCapability( purpose=claim_purpose, subject=claim_subject, audience=claim_audience, issued_at=issued_at, expires_at=expires_at, token_id=claim_token_id, claims=application_claims, ) def _numeric_date(value: JSONValue | None) -> datetime | None: if isinstance(value, bool) or not isinstance(value, int | float): return None try: return datetime.fromtimestamp(value, tz=timezone.utc) except (OverflowError, OSError, ValueError): return None def _copy_json(value: object, *, depth: int = 1) -> JSONValue: if depth > _MAXIMUM_APPLICATION_CLAIM_DEPTH: raise_value("Capability application claims must be bounded JSON values") if value is None or value.__class__ in (bool, int, str): return value # pyright: ignore[reportReturnType] - exact runtime classes are narrowed above if value.__class__ is float: if not isfinite(value): # pyright: ignore[reportArgumentType] - exact float class is required above raise_value("Capability application claims must be finite JSON values") return value # pyright: ignore[reportReturnType] - exact float class is required above if value.__class__ is list: return [_copy_json(item, depth=depth + 1) for item in cast("list[object]", value)] if value.__class__ is dict: copied: dict[str, JSONValue] = {} for key, item in cast("dict[object, object]", value).items(): if key.__class__ is not str: raise_value("Capability application claims must use JSON object keys") copied[key] = _copy_json(item, depth=depth + 1) # pyright: ignore[reportArgumentType] - exact str key return copied return raise_value("Capability application claims must be JSON values")