Coverage for src/lexigram/auth/web/middleware/token_cache.py: 66%
74 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
1"""Token caching utilities for authentication middleware."""
3from __future__ import annotations
5from collections import OrderedDict
6import hashlib
7import threading
8from typing import TYPE_CHECKING, Any
10from lexigram.primitives import clock as ambient_clock
12if TYPE_CHECKING:
13 from lexigram.contracts.infra.cache import CacheBackendProtocol
16class TokenCache:
17 """Cache for JWT tokens with LRU eviction and TTL support.
19 Implements:
20 - TTL (time-to-live) for entries
21 - LRU (least recently used) eviction when max_size is reached
22 - Automatic cleanup of expired entries
23 - Optional backend cache integration via CacheBackendProtocol protocol
25 When no CacheBackendProtocol is provided, this falls back to an in-memory LRU
26 cache (L1 layer using :class:`collections.OrderedDict`). For production
27 deployments—especially multi-worker or multi-host setups—inject a
28 distributed backend. The recommended options are:
30 * ``lexigram.cache.backends.memory.MemoryCacheBackend`` (from
31 *lexigram-cache*) for a process-level shared cache with richer
32 configuration.
33 * A Redis-backed ``CacheBackendProtocol`` implementation for cross-process token
34 invalidation.
36 Note: ``lexigram-auth`` does not import from ``lexigram-cache`` directly
37 (that would violate the extension-package boundary); pass the backend
38 via constructor injection instead.
39 """
41 def __init__(
42 self,
43 max_size: int = 10_000,
44 ttl_seconds: float = 300.0,
45 cache_backend: CacheBackendProtocol | None = None,
46 ):
47 """Initialize token cache.
49 Args:
50 max_size: Maximum number of tokens to cache in L1 (local) layer.
51 ttl_seconds: Time-to-live for cached entries in seconds.
52 cache_backend: Optional L2 cache backend for distributed caching.
53 """
54 self._lock = threading.Lock()
55 self._cache: OrderedDict[str, dict[str, Any]] = OrderedDict()
56 self._max_size = max_size
57 self._ttl = ttl_seconds
58 self._backend = cache_backend
59 self._hits = 0
60 self._misses = 0
62 def _cleanup_expired(self) -> None:
63 """Remove expired entries from cache."""
64 now = ambient_clock.monotonic()
65 expired_keys = [k for k, v in self._cache.items() if v["expires_at"] <= now]
66 for k in expired_keys:
67 del self._cache[k]
69 def _evict_if_needed(self) -> None:
70 """Evict oldest entry if cache is full."""
71 while len(self._cache) >= self._max_size:
72 self._cache.popitem(last=False)
74 async def get(self, token: str) -> Any | None:
75 """Get cached user for token, or None if not cached/expired.
77 Checks L1 (local) first, then L2 (backend) if available.
78 """
79 now = ambient_clock.monotonic()
80 token_hash = hashlib.sha256(token.encode()).hexdigest()
82 with self._lock:
83 cached = self._cache.get(token_hash)
85 if cached is not None and cached["expires_at"] > now:
86 self._hits += 1
87 self._cache.move_to_end(token_hash)
88 return cached["user"]
90 if cached is not None:
91 del self._cache[token_hash]
93 if self._backend is not None:
94 cached = None
95 get_result = await self._backend.get(f"token:{token_hash}")
96 if get_result.is_ok():
97 cached = get_result.unwrap()
98 if cached is not None:
99 self._hits += 1
100 await self.set(token, cached)
101 return cached
103 self._misses += 1
104 return None
106 async def set(self, token: str, user: Any) -> None:
107 """Cache user for token.
109 Stores in both L1 (local) and L2 (backend) if available.
110 """
111 token_hash = hashlib.sha256(token.encode()).hexdigest()
112 expires_at = ambient_clock.monotonic() + self._ttl
114 with self._lock:
115 self._cleanup_expired()
116 self._evict_if_needed()
118 self._cache[token_hash] = {
119 "user": user,
120 "expires_at": expires_at,
121 }
122 self._cache.move_to_end(token_hash)
124 if self._backend is not None:
125 await self._backend.set(f"token:{token_hash}", user, ttl=int(self._ttl))
127 async def invalidate(self, token: str) -> None:
128 """Invalidate a specific token from cache."""
129 token_hash = hashlib.sha256(token.encode()).hexdigest()
131 with self._lock:
132 self._cache.pop(token_hash, None)
134 if self._backend is not None:
135 await self._backend.delete(f"token:{token_hash}")
137 def clear(self) -> None:
138 """Clear all cached tokens."""
139 with self._lock:
140 self._cache.clear()
141 self._hits = 0
142 self._misses = 0
144 if self._backend is not None:
145 pass
147 def stats(self) -> dict[str, Any]:
148 """Get cache statistics.
150 Returns:
151 Dict with hits, misses, size, max_size, and hit_rate.
152 """
153 with self._lock:
154 total = self._hits + self._misses
155 hit_rate = (self._hits / total * 100) if total > 0 else 0.0
156 return {
157 "hits": self._hits,
158 "misses": self._misses,
159 "size": len(self._cache),
160 "max_size": self._max_size,
161 "hit_rate_percent": round(hit_rate, 2),
162 }
165__all__ = ["TokenCache"]