Source code for litestar_security.providers.jwt._verification

"""Verifier protocols, the PyJWT-backed verifier, and unverified route parsing.

Route parsing deliberately reads an unverified token and returns only the issuer
and key hints needed to select trust material. It is kept beside the verifier so
the boundary between hint and verified claim stays visible in one file.
"""

from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from datetime import datetime, timezone
from functools import partial
from inspect import iscoroutinefunction
from math import isfinite
from types import MappingProxyType
from typing import Generic, Protocol, TypeAlias, TypeVar, cast, runtime_checkable

import jwt
from anyio import CapacityLimiter
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa
from jwt import PyJWK
from jwt.exceptions import PyJWTError

from litestar_security.authentication import (
    Authenticated,
    AuthenticationOutcome,
    InvalidCredentials,
    VerificationUnavailable,
)
from litestar_security.context import AuthenticationEvidence
from litestar_security.providers._internal import JSONValue, raise_config
from litestar_security.providers.jwt._claims import (
    JWTAlgorithm,
    JWTClaims,
    JWTValidationConfig,
    normalize_claims,
    validate_header,
)
from litestar_security.providers.jwt._internal import (
    decode_base64url,
    decode_json_segment,
    freeze_json,
    strict_identifier,
)
from litestar_security.providers.jwt._keys import prepare_key
from litestar_security.providers.jwt._workers import metric_sink, run_worker, validate_limiter
from litestar_security.workers import NoOpSecurityMetrics, SecurityMetrics, WorkerLimits

__all__ = ("JWTVerifier", "SyncJWTVerifier", "normalize_verifier")


VerificationKeyInput: TypeAlias = bytes | str | PyJWK | Mapping[str, JSONValue]


PreparedVerificationKey: TypeAlias = (
    bytes | str | PyJWK | rsa.RSAPublicKey | ec.EllipticCurvePublicKey | ed25519.Ed25519PublicKey
)


ClaimsT = TypeVar("ClaimsT")


_COMPACT_SEGMENT_COUNT = 3


_INVALID = InvalidCredentials()


[docs] @runtime_checkable class JWTVerifier(Protocol, Generic[ClaimsT]): """Verify one compact JWT against a configured trust domain.""" @property def config(self) -> JWTValidationConfig: """Return the verifier's pinned trust profile.""" ... # pragma: no cover
[docs] async def verify(self, token: str, *, now: datetime) -> AuthenticationOutcome[ClaimsT]: """Return a structured authentication outcome. Args: token: The compact JWT to verify. now: The verification timestamp, used for expiry and not-before checks. Returns: The verified claims, or a sanitized outcome. A rejected signature and a rejected claim are not distinguished. """ ... # pragma: no cover
[docs] @runtime_checkable class SyncJWTVerifier(Protocol, Generic[ClaimsT]): """Blocking custom verifier normalized once into the crypto worker.""" @property def config(self) -> JWTValidationConfig: """Return the verifier's pinned trust profile.""" ... # pragma: no cover
[docs] def verify(self, token: str, *, now: datetime) -> AuthenticationOutcome[ClaimsT]: """Return a structured authentication outcome. Args: token: The compact JWT to verify. now: The verification timestamp, used for expiry and not-before checks. Returns: The verified claims, or a sanitized outcome. A rejected signature and a rejected claim are not distinguished. """ ... # pragma: no cover
[docs] def normalize_verifier( verifier: JWTVerifier[ClaimsT] | SyncJWTVerifier[ClaimsT], *, worker_limits: WorkerLimits | None = None, metrics: SecurityMetrics | None = None, ) -> JWTVerifier[ClaimsT]: """Normalize one custom verifier once without blocking the event loop. Args: verifier: The application's verifier, blocking or async. worker_limits: The shared crypto-worker budget a blocking verifier runs inside. metrics: The sink offered verification measurements. Returns: An async verifier. """ verify_method = getattr(verifier, "verify", None) config = getattr(verifier, "config", None) if not callable(verify_method) or not isinstance(config, JWTValidationConfig): raise_config("JWT verifier must define verify and JWTValidationConfig") workers = WorkerLimits() if worker_limits is None else worker_limits if not isinstance(workers, WorkerLimits): # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime port boundary raise_config("JWT verifier worker limits must be WorkerLimits") sink = metric_sink(metrics) if iscoroutinefunction(verify_method): return cast("JWTVerifier[ClaimsT]", verifier) return _WorkerJWTVerifier( config=config, verify_sync=cast("Callable[..., AuthenticationOutcome[ClaimsT]]", verify_method), workers=workers, metrics=sink, )
@dataclass(frozen=True, slots=True) class UnverifiedJWTRoute: """Strictly parsed but cryptographically untrusted JOSE routing data.""" header: Mapping[str, JSONValue] payload: Mapping[str, JSONValue] def parse_unverified_jwt_route( token: str, *, maximum_token_bytes: int = 16_384, maximum_json_depth: int = 32 ) -> UnverifiedJWTRoute | InvalidCredentials: """Parse untrusted JOSE routing fields without treating them as claims.""" if maximum_token_bytes < 1 or maximum_json_depth < 1: return _INVALID try: encoded = token.encode("ascii") except (AttributeError, UnicodeEncodeError): return _INVALID if len(encoded) > maximum_token_bytes: return _INVALID segments = token.split(".") if len(segments) != _COMPACT_SEGMENT_COUNT or any(not segment for segment in segments): return _INVALID try: header = decode_json_segment(segments[0], maximum_json_depth=maximum_json_depth) payload = decode_json_segment(segments[1], maximum_json_depth=maximum_json_depth) decode_base64url(segments[2]) except (TypeError, ValueError): return _INVALID return UnverifiedJWTRoute( header=cast("Mapping[str, JSONValue]", freeze_json(header)), payload=cast("Mapping[str, JSONValue]", freeze_json(payload)), ) @dataclass(frozen=True, slots=True) class PyJWTVerifier: """Verify one fixed-key JWT profile with PyJWT's signature primitive.""" config: JWTValidationConfig key: VerificationKeyInput = field(repr=False) require_key_id: bool = True mechanism_name: str = "jwt" slot_name: str = "authorization.bearer" maximum_token_bytes: int = 16_384 limiter: CapacityLimiter | None = field(default=None, repr=False, compare=False) worker_timeout: float = field(default=10.0, repr=False, compare=False) metrics: SecurityMetrics = field(default_factory=NoOpSecurityMetrics, repr=False, compare=False) _prepared_keys: Mapping[str, PreparedVerificationKey] = field(init=False, repr=False) def __post_init__(self) -> None: """Validate and prepare fixed verification material once.""" if self.maximum_token_bytes < 1: raise_config("JWT maximum token bytes must be positive") if ( self.worker_timeout.__class__ not in {int, float} or not isfinite(self.worker_timeout) or self.worker_timeout <= 0 ): raise_config("JWT worker timeout must be finite and positive") limiter = WorkerLimits().crypto_limiter if self.limiter is None else validate_limiter(self.limiter) metrics = metric_sink(self.metrics) mechanism_name = strict_identifier(self.mechanism_name) slot_name = strict_identifier(self.slot_name) prepared: dict[str, PreparedVerificationKey] = {} for algorithm in self.config.algorithms: prepared[algorithm] = prepare_key(self.key, cast("JWTAlgorithm", algorithm)) object.__setattr__(self, "mechanism_name", mechanism_name) object.__setattr__(self, "slot_name", slot_name) object.__setattr__(self, "limiter", limiter) object.__setattr__(self, "worker_timeout", float(self.worker_timeout)) object.__setattr__(self, "metrics", metrics) object.__setattr__(self, "_prepared_keys", MappingProxyType(prepared)) async def verify(self, token: str, *, now: datetime) -> AuthenticationOutcome[JWTClaims]: # noqa: PLR0911 - preserve explicit sanitized outcomes at each security boundary """Verify signature and claims, returning only sanitized outcomes.""" if now.tzinfo is None or now.utcoffset() is None: return _INVALID now = now.astimezone(timezone.utc) route = parse_unverified_jwt_route(token, maximum_token_bytes=self.maximum_token_bytes) if isinstance(route, InvalidCredentials): return route header_result = validate_header(route.header, self.config, require_key_id=self.require_key_id) if isinstance(header_result, InvalidCredentials): return header_result algorithm = header_result claims = normalize_claims(route.payload, self.config, now=now) if isinstance(claims, InvalidCredentials): return claims verify = partial(_verify_signature, token, self._prepared_keys[algorithm], algorithm) try: await run_worker( verify, limiter=cast("CapacityLimiter", self.limiter), worker_timeout=self.worker_timeout, metrics=self.metrics, operation_metric="security.jwt.verify_duration", ) except (PyJWTError, TypeError, ValueError): return _INVALID except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() return Authenticated( claims=claims, evidence=AuthenticationEvidence( mechanism=self.mechanism_name, slot=self.slot_name, authenticated_at=now, expires_at=claims.expires_at ), ) @dataclass(frozen=True, slots=True) class _WorkerJWTVerifier(Generic[ClaimsT]): config: JWTValidationConfig verify_sync: Callable[..., AuthenticationOutcome[ClaimsT]] = field(repr=False) workers: WorkerLimits = field(repr=False) metrics: SecurityMetrics = field(repr=False) async def verify(self, token: str, *, now: datetime) -> AuthenticationOutcome[ClaimsT]: try: return await run_worker( partial(self.verify_sync, token, now=now), limiter=self.workers.crypto_limiter, worker_timeout=self.workers.timeout, metrics=self.metrics, operation_metric="security.jwt.verify_duration", ) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() def _verify_signature(token: str, key: PreparedVerificationKey, algorithm: str) -> None: jwt.decode_complete( token, key=key, # pyright: ignore[reportArgumentType] - third-party signature is wider than its runtime contract algorithms=[algorithm], options={ "require": [], "verify_aud": False, "verify_exp": False, "verify_iat": False, "verify_iss": False, "verify_jti": False, "verify_nbf": False, "verify_signature": True, "verify_sub": False, }, )