"""In-memory cache for token verification results. Provides a generic TTL-based cache for ``AccessToken`` objects, designed to reduce repeated network calls during opaque-token verification. Only *successful* verifications should be cached; errors and failures must be retried on every request. Example: ```python from fastmcp.utilities.token_cache import TokenCache cache = TokenCache(ttl_seconds=300, max_size=10000) # On cache miss, call the upstream verifier and store the result. hit, token = cache.get(raw_token) if not hit: token = await _call_upstream(raw_token) if token is not None: cache.set(raw_token, token) ``` """ from __future__ import annotations import hashlib import time from dataclasses import dataclass from fastmcp.server.auth.auth import AccessToken from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) DEFAULT_MAX_CACHE_SIZE = 10_000 _CLEANUP_INTERVAL = 60 # seconds between periodic sweeps @dataclass class _CacheEntry: """A cached token result with its absolute expiration timestamp.""" result: AccessToken expires_at: float class TokenCache: """TTL-based in-memory cache for ``AccessToken`` objects. Features: - SHA-256 hashed cache keys (fixed size, regardless of token length). - Per-entry TTL that respects both the configured ``ttl_seconds`` and the token's own ``expires_at`` claim (whichever is sooner). - Bounded size with FIFO eviction when the cache is full. - Periodic cleanup of expired entries to prevent unbounded growth. - Defensive deep copies on both store and retrieve to prevent callers from mutating cached values. Caching is disabled when ``ttl_seconds`` is ``None`` or ``0``, or when ``max_size`` is ``0``. Negative values raise ``ValueError``. """ def __init__( self, *, ttl_seconds: int | None = None, max_size: int | None = None, ) -> None: """Initialise the cache. Args: ttl_seconds: How long cached entries remain valid, in seconds. ``None`` or ``0`` disables caching entirely. max_size: Upper bound on the number of entries. When the limit is reached, expired entries are swept first; if still full the oldest entry is evicted. Defaults to 10 000. """ if ttl_seconds is not None and ttl_seconds < 0: raise ValueError( f"cache_ttl_seconds must be non-negative, got {ttl_seconds}" ) if max_size is not None and max_size < 0: raise ValueError(f"max_cache_size must be non-negative, got {max_size}") self._ttl = ttl_seconds or 0 self._max_size = max_size if max_size is not None else DEFAULT_MAX_CACHE_SIZE self._entries: dict[str, _CacheEntry] = {} self._last_cleanup = time.monotonic() @property def enabled(self) -> bool: """Return whether caching is active.""" return self._ttl > 0 and self._max_size > 0 # -- public API ---------------------------------------------------------- def get(self, token: str) -> tuple[bool, AccessToken | None]: """Look up a cached verification result. Returns: ``(True, AccessToken)`` on a cache hit, ``(False, None)`` on a miss or when caching is disabled. The returned ``AccessToken`` is a deep copy that is safe to mutate. """ if not self.enabled: return (False, None) cache_key = self._hash_token(token) entry = self._entries.get(cache_key) if entry is None: return (False, None) if entry.expires_at < time.time(): del self._entries[cache_key] return (False, None) return (True, entry.result.model_copy(deep=True)) def set(self, token: str, result: AccessToken) -> None: """Store a *successful* verification result. Only successful verifications should be cached. Failures (inactive tokens, missing scopes, HTTP errors, timeouts) must **not** be cached so that transient problems do not produce sticky false negatives. """ if not self.enabled: return cache_key = self._hash_token(token) self._maybe_cleanup() if cache_key not in self._entries: self._enforce_size_limit() expires_at = time.time() + self._ttl if result.expires_at: expires_at = min(expires_at, float(result.expires_at)) self._entries[cache_key] = _CacheEntry( result=result.model_copy(deep=True), expires_at=expires_at, ) # -- internals ----------------------------------------------------------- @staticmethod def _hash_token(token: str) -> str: """Return the SHA-256 hex digest of *token*.""" return hashlib.sha256(token.encode("utf-8")).hexdigest() def _cleanup_expired(self) -> None: """Remove all entries whose TTL has elapsed.""" now = time.time() expired = [k for k, v in self._entries.items() if v.expires_at < now] for key in expired: del self._entries[key] if expired: logger.debug("Cleaned up %d expired cache entries", len(expired)) def _maybe_cleanup(self) -> None: """Run ``_cleanup_expired`` at most once per cleanup interval.""" now = time.monotonic() if now - self._last_cleanup > _CLEANUP_INTERVAL: self._cleanup_expired() self._last_cleanup = now def _enforce_size_limit(self) -> None: """Ensure there is room for at least one new entry.""" if len(self._entries) < self._max_size: return self._cleanup_expired() if len(self._entries) >= self._max_size: oldest_key = next(iter(self._entries)) del self._entries[oldest_key]