Source code for litestar_security.providers.jwks._cache

"""Cache policy, the configured source entry, and the shareable key snapshot.

Snapshots are immutable: a refresh builds a new one and replaces the old one
atomically, so a reader never observes a half-updated key set. The store holding
them is swappable, which is how two components in one application share a single
key set and a single fetch schedule.
"""

from collections import OrderedDict
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Protocol, TypeAlias, runtime_checkable

from anyio import Lock

from litestar_security.providers._internal import raise_config
from litestar_security.providers.jwks._internal import strict_value
from litestar_security.providers.jwt import VerificationKey

__all__ = ("InMemoryJWKSCache", "JWKSCache", "JWKSCacheCoordinator", "JWKSCachePolicy", "JWKSSnapshot", "JWKSSource")


SelectionKey: TypeAlias = tuple[str, str]
"""The exact ``(kid, algorithm)`` pair a token header names."""


_DEFAULT_TTL = timedelta(minutes=15)


_MINIMUM_TTL = timedelta(seconds=30)


_MAXIMUM_TTL = timedelta(hours=24)


_UNKNOWN_KID_COOLDOWN = timedelta(seconds=30)


_MAXIMUM_DOCUMENT_BYTES = 1_048_576


_MAXIMUM_KEYS = 128


_MAXIMUM_UNKNOWN_KEYS = 1_024


_SUPPORTED_REMOTE_ALGORITHMS = frozenset({"EdDSA", "ES256", "RS256"})


[docs] @dataclass(frozen=True, slots=True) class JWKSSource: """One exact configured issuer and JWKS source.""" issuer: str jwks_uri: str algorithms: frozenset[str] def __post_init__(self) -> None: """Normalize immutable algorithms and reject ambiguous identifiers.""" issuer = strict_value(self.issuer, "JWKS issuer") jwks_uri = strict_value(self.jwks_uri, "JWKS URI") algorithms = frozenset(self.algorithms) if not algorithms or not algorithms.issubset(_SUPPORTED_REMOTE_ALGORITHMS): raise_config("JWKS entry requires supported asymmetric signing algorithms") object.__setattr__(self, "issuer", issuer) object.__setattr__(self, "jwks_uri", jwks_uri) object.__setattr__(self, "algorithms", algorithms)
def freshness(headers: Mapping[str, str], policy: "JWKSCachePolicy", now: datetime) -> tuple[datetime, datetime]: directives = tuple(part.strip().lower() for part in headers.get("cache-control", "").split(",") if part.strip()) no_store = "no-store" in directives no_cache = "no-cache" in directives max_ages = tuple(part.partition("=")[2].strip('"') for part in directives if part.partition("=")[0] == "max-age") ttl = policy.default_ttl if len(max_ages) == 1 and max_ages[0].isdecimal(): ttl = timedelta(seconds=int(max_ages[0])) ttl = max(policy.minimum_ttl, min(ttl, policy.maximum_ttl)) if no_store or no_cache: ttl = timedelta(0) fresh_until = now + ttl stale_until = fresh_until if no_store else fresh_until + policy.stale_if_error return fresh_until, stale_until
[docs] @dataclass(frozen=True, slots=True) class JWKSCachePolicy: """Local freshness and bounded-document policy for remote JWKS entries.""" default_ttl: timedelta = _DEFAULT_TTL minimum_ttl: timedelta = _MINIMUM_TTL maximum_ttl: timedelta = _MAXIMUM_TTL unknown_kid_cooldown: timedelta = _UNKNOWN_KID_COOLDOWN stale_if_error: timedelta = timedelta(0) warm_on_startup: bool = False maximum_document_bytes: int = _MAXIMUM_DOCUMENT_BYTES maximum_keys: int = _MAXIMUM_KEYS maximum_unknown_keys: int = _MAXIMUM_UNKNOWN_KEYS def __post_init__(self) -> None: """Reject unsafe or contradictory cache bounds.""" durations: tuple[object, ...] = ( self.default_ttl, self.minimum_ttl, self.maximum_ttl, self.unknown_kid_cooldown, self.stale_if_error, ) if any( not isinstance(value, timedelta) # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime port boundary for value in durations ): raise_config("JWKS cache durations must be timedeltas") if ( not isinstance( # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime port boundary self.warm_on_startup, bool ) or self.minimum_ttl <= timedelta(0) or self.maximum_ttl < self.minimum_ttl or not self.minimum_ttl <= self.default_ttl <= self.maximum_ttl or self.unknown_kid_cooldown <= timedelta(0) or self.stale_if_error < timedelta(0) ): raise_config("JWKS cache durations must be positive, ordered, and bounded") if ( isinstance(self.maximum_document_bytes, bool) or not isinstance(self.maximum_document_bytes, int) # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime port boundary or not 1 <= self.maximum_document_bytes <= _MAXIMUM_DOCUMENT_BYTES or isinstance(self.maximum_keys, bool) or not isinstance(self.maximum_keys, int) # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime port boundary or not 1 <= self.maximum_keys <= _MAXIMUM_KEYS or isinstance(self.maximum_unknown_keys, bool) or not isinstance(self.maximum_unknown_keys, int) # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime port boundary or not 1 <= self.maximum_unknown_keys <= _MAXIMUM_UNKNOWN_KEYS ): raise_config("JWKS cache limits must be positive and bounded")
[docs] @dataclass(frozen=True, slots=True) class JWKSSnapshot: """One immutable parsed key set together with its freshness bounds. Args: keys: Verification keys indexed by the exact ``(kid, algorithm)`` pair a token header names. etag: The entity tag the source returned, used for conditional refresh. fresh_until: When the snapshot stops being served without a refresh. stale_until: How long the snapshot may still answer while the source is unreachable. generation: Increases on every parsed replacement, so a consumer can tell a rotation from a revalidation. source_uri: The key set this snapshot was parsed from. """ keys: Mapping[SelectionKey, VerificationKey] etag: str | None fresh_until: datetime stale_until: datetime generation: int source_uri: str
[docs] @dataclass(slots=True) class JWKSCacheCoordinator: """Share refresh and negative-key state for one exact cache entry. Cache implementations return the same coordinator for repeated requests for one exact ``(issuer, jwks_uri)`` pair. Applications normally only construct this value while implementing :class:`JWKSCache`; providers manage its contents. Args: lock: Lock serializing refresh and negative-key changes. refresh: Opaque in-flight refresh state owned by a provider. forced_generation: The generation whose unknown-key refresh was used. negative: Bounded generation-scoped unknown-key expirations. users: Number of providers attached to this coordination state. """ lock: Lock = field(default_factory=Lock) refresh: object | None = None forced_generation: int | None = None negative: OrderedDict[tuple[int, str, str], datetime] = field( default_factory=OrderedDict[tuple[int, str, str], datetime] ) users: int = 0
[docs] @runtime_checkable class JWKSCache(Protocol): """Store remote key snapshots so components can share one fetch schedule. An implementer must honor three invariants: - **Snapshots are immutable.** Store and return the value as given; never mutate one in place, and never hand back a partially populated key set. - **``set`` is last-write-wins.** The most recent write for a key is the one a later ``get`` returns. No merging, no ordering by generation. - **A miss is indistinguishable from an expired entry.** Returning ``None`` is always safe: the caller refetches. An implementation may therefore evict, expire, or bound itself however it likes, and must never fabricate or extend a snapshot to avoid a miss. Methods are synchronous because they sit on the token-verification hot path, where the fresh read must not await. """
[docs] def get(self, issuer: str, jwks_uri: str) -> "JWKSSnapshot | None": """Return the stored snapshot for one configured source. Args: issuer: The configured issuer the snapshot belongs to. jwks_uri: The key set the snapshot was parsed from. Returns: The stored snapshot, or ``None`` when nothing is stored. """ ... # pragma: no cover
[docs] def set(self, issuer: str, jwks_uri: str, snapshot: "JWKSSnapshot") -> None: """Store the newest snapshot for one configured source. Args: issuer: The configured issuer the snapshot belongs to. jwks_uri: The key set the snapshot was parsed from. snapshot: The immutable snapshot to store. """ ... # pragma: no cover
[docs] def invalidate(self, issuer: str, jwks_uri: str) -> None: """Drop any snapshot stored for one configured source. Dropping an absent entry is not an error. Args: issuer: The configured issuer the snapshot belongs to. jwks_uri: The key set the snapshot was parsed from. """ ... # pragma: no cover
[docs] def coordinator(self, issuer: str, jwks_uri: str) -> JWKSCacheCoordinator: """Return stable coordination state for one configured source. Calls for the same exact pair must return the same object so providers sharing this cache also share refresh and unknown-key coordination. Args: issuer: The configured issuer the coordination belongs to. jwks_uri: The configured key-set URI. Returns: Stable coordination state for the exact source pair. """ ... # pragma: no cover
[docs] class InMemoryJWKSCache: """Hold key snapshots for the lifetime of one process. This is the default. Construct one explicitly and hand it to several providers to give them a shared key set and a shared fetch schedule. """ __slots__ = ("_coordinators", "_entries")
[docs] def __init__(self) -> None: """Start with no stored snapshots.""" self._entries: dict[SelectionKey, JWKSSnapshot] = {} self._coordinators: dict[SelectionKey, JWKSCacheCoordinator] = {}
[docs] def get(self, issuer: str, jwks_uri: str) -> "JWKSSnapshot | None": """Return the stored snapshot for one configured source. Args: issuer: The configured issuer the snapshot belongs to. jwks_uri: The key set the snapshot was parsed from. Returns: The stored snapshot, or ``None`` when nothing is stored. """ return self._entries.get((issuer, jwks_uri))
[docs] def set(self, issuer: str, jwks_uri: str, snapshot: "JWKSSnapshot") -> None: """Store the newest snapshot for one configured source. Args: issuer: The configured issuer the snapshot belongs to. jwks_uri: The key set the snapshot was parsed from. snapshot: The immutable snapshot to store. """ self._entries[issuer, jwks_uri] = snapshot
[docs] def invalidate(self, issuer: str, jwks_uri: str) -> None: """Drop any snapshot stored for one configured source. Args: issuer: The configured issuer the snapshot belongs to. jwks_uri: The key set the snapshot was parsed from. """ self._entries.pop((issuer, jwks_uri), None)
[docs] def coordinator(self, issuer: str, jwks_uri: str) -> JWKSCacheCoordinator: """Return stable coordination state for one configured source. Args: issuer: The configured issuer the coordination belongs to. jwks_uri: The configured key-set URI. Returns: Stable coordination state for the exact source pair. """ return self._coordinators.setdefault((issuer, jwks_uri), JWKSCacheCoordinator())