|
12 | 12 | tracked in #190 and not implemented here — the current design is vulnerable to |
13 | 13 | a TOCTOU where DNS resolves to an allowed IP during validation and a blocked |
14 | 14 | IP at connect time. |
| 15 | +
|
| 16 | +Naming conventions |
| 17 | +------------------ |
| 18 | +* Classes use the ``Async`` CapWords prefix (``AsyncCachingJwksResolver``). |
| 19 | +* Free functions use the ``async_`` snake_case prefix |
| 20 | + (``async_default_jwks_fetcher``). |
| 21 | +* Methods use the ``a`` prefix (``aclose``, ``aprime``) — matches the |
| 22 | + ``httpx`` / ``anyio`` ecosystem. |
15 | 23 | """ |
16 | 24 |
|
17 | 25 | from __future__ import annotations |
18 | 26 |
|
| 27 | +import asyncio |
19 | 28 | import ipaddress |
20 | 29 | import socket |
21 | 30 | import time |
@@ -60,18 +69,43 @@ class JwksFetcher(Protocol): |
60 | 69 | def __call__(self, uri: str, *, allow_private: bool = False) -> dict[str, Any]: ... |
61 | 70 |
|
62 | 71 |
|
| 72 | +class AsyncJwksFetcher(Protocol): |
| 73 | + """Async variant of :class:`JwksFetcher`.""" |
| 74 | + |
| 75 | + async def __call__( |
| 76 | + self, uri: str, *, allow_private: bool = False |
| 77 | + ) -> dict[str, Any]: ... |
| 78 | + |
| 79 | + |
63 | 80 | class JwksResolver(Protocol): |
64 | 81 | """Resolves a keyid to a JWK, or returns None if unknown. |
65 | 82 |
|
66 | | - The canonical Protocol used by both the RFC 9421 verifier and the JWS |
67 | | - document verifier. Implementations include |
| 83 | + The canonical Protocol used by the sync RFC 9421 verifier and the |
| 84 | + sync JWS document verifier. Implementations include |
68 | 85 | :class:`StaticJwksResolver` (in-memory, for tests) and |
69 | 86 | :class:`CachingJwksResolver` (fetches + caches from a URI). |
| 87 | +
|
| 88 | + Async callers use :class:`AsyncJwksResolver` instead. |
70 | 89 | """ |
71 | 90 |
|
72 | 91 | def __call__(self, keyid: str) -> dict[str, Any] | None: ... |
73 | 92 |
|
74 | 93 |
|
| 94 | +class AsyncJwksResolver(Protocol): |
| 95 | + """Async variant of :class:`JwksResolver`. |
| 96 | +
|
| 97 | + Used by the async JWS document verifier and the async revocation |
| 98 | + checker so JWKS cache-misses don't block the event loop. |
| 99 | + Implementations: :class:`AsyncCachingJwksResolver`. For tests, |
| 100 | + :class:`StaticJwksResolver` doubles as an async resolver if you wrap |
| 101 | + it in a thin async callable — there's no async work, just a dict |
| 102 | + lookup — but typically you'll just use the static one directly where |
| 103 | + an :class:`AsyncJwksResolver` is expected via :func:`as_async`. |
| 104 | + """ |
| 105 | + |
| 106 | + async def __call__(self, keyid: str) -> dict[str, Any] | None: ... |
| 107 | + |
| 108 | + |
75 | 109 | def validate_jwks_uri(uri: str, *, allow_private: bool = False) -> None: |
76 | 110 | """Raise SSRFValidationError if `uri` resolves to a blocked IP or has a bad scheme.""" |
77 | 111 | parts = urlsplit(uri) |
@@ -200,15 +234,140 @@ def __call__(self, keyid: str) -> dict[str, Any] | None: |
200 | 234 | return self._keys.get(keyid) |
201 | 235 |
|
202 | 236 |
|
| 237 | +# --------------------------------------------------------------------------- |
| 238 | +# Async variants |
| 239 | +# --------------------------------------------------------------------------- |
| 240 | + |
| 241 | + |
| 242 | +async def async_default_jwks_fetcher( |
| 243 | + uri: str, *, allow_private: bool = False |
| 244 | +) -> dict[str, Any]: |
| 245 | + """Async counterpart to :func:`default_jwks_fetcher`. |
| 246 | +
|
| 247 | + Uses :class:`httpx.AsyncClient` so callers on an asyncio event loop |
| 248 | + don't block the loop on JWKS fetches. Same SSRF + follow-redirects |
| 249 | + rules as the sync version. |
| 250 | + """ |
| 251 | + validate_jwks_uri(uri, allow_private=allow_private) |
| 252 | + async with httpx.AsyncClient( |
| 253 | + timeout=DEFAULT_JWKS_TIMEOUT_SECONDS, follow_redirects=False |
| 254 | + ) as client: |
| 255 | + response = await client.get(uri, headers={"Accept": "application/json"}) |
| 256 | + response.raise_for_status() |
| 257 | + body = response.json() |
| 258 | + if not isinstance(body, dict) or "keys" not in body: |
| 259 | + raise ValueError(f"JWKS document at {uri!r} has no 'keys' array") |
| 260 | + return body |
| 261 | + |
| 262 | + |
| 263 | +class AsyncCachingJwksResolver: |
| 264 | + """Async JWKS resolver with per-URI cache and refetch cooldown. |
| 265 | +
|
| 266 | + Identical semantics to :class:`CachingJwksResolver` — per-``kid`` |
| 267 | + cache, cooldown-gated refresh on miss, SSRF errors surface as |
| 268 | + ``request_signature_jwks_untrusted``, network errors as |
| 269 | + ``request_signature_jwks_unavailable`` — but awaitable and backed by |
| 270 | + :class:`httpx.AsyncClient`. |
| 271 | +
|
| 272 | + Concurrency: a single :class:`asyncio.Lock` serializes refreshes so |
| 273 | + N parallel verifying tasks all driving the first miss don't fire N |
| 274 | + fetches. |
| 275 | + """ |
| 276 | + |
| 277 | + def __init__( |
| 278 | + self, |
| 279 | + jwks_uri: str, |
| 280 | + *, |
| 281 | + fetcher: AsyncJwksFetcher | None = None, |
| 282 | + cooldown_seconds: float = DEFAULT_JWKS_COOLDOWN_SECONDS, |
| 283 | + allow_private: bool = False, |
| 284 | + clock: Callable[[], float] = time.monotonic, |
| 285 | + ) -> None: |
| 286 | + self._jwks_uri = jwks_uri |
| 287 | + self._fetcher = fetcher or async_default_jwks_fetcher |
| 288 | + self._cooldown = cooldown_seconds |
| 289 | + self._allow_private = allow_private |
| 290 | + self._clock = clock |
| 291 | + self._cache: dict[str, dict[str, Any]] = {} |
| 292 | + self._last_attempt: float | None = None |
| 293 | + self._primed = False |
| 294 | + # Construct the lock eagerly. Lazy init was racy: two tasks both |
| 295 | + # seeing ``self._lock is None`` would each construct a separate |
| 296 | + # Lock and proceed in parallel without serialization. In Python |
| 297 | + # 3.10+ ``asyncio.Lock()`` no longer requires a running loop at |
| 298 | + # construction time — it binds to whatever loop is running the |
| 299 | + # first ``async with`` call. Instances are per-loop: don't share |
| 300 | + # across ``asyncio.run`` boundaries. |
| 301 | + self._lock: asyncio.Lock = asyncio.Lock() |
| 302 | + |
| 303 | + async def __call__(self, keyid: str) -> dict[str, Any] | None: |
| 304 | + if keyid in self._cache: |
| 305 | + return self._cache[keyid] |
| 306 | + now = self._clock() |
| 307 | + if not self._primed or ( |
| 308 | + self._last_attempt is not None and now - self._last_attempt >= self._cooldown |
| 309 | + ): |
| 310 | + async with self._lock: |
| 311 | + # Re-check after acquiring: another task may have refreshed. |
| 312 | + if keyid in self._cache: |
| 313 | + return self._cache[keyid] |
| 314 | + now = self._clock() |
| 315 | + if not self._primed or ( |
| 316 | + self._last_attempt is not None |
| 317 | + and now - self._last_attempt >= self._cooldown |
| 318 | + ): |
| 319 | + await self._refresh(now) |
| 320 | + return self._cache.get(keyid) |
| 321 | + |
| 322 | + async def _refresh(self, now: float) -> None: |
| 323 | + self._last_attempt = now |
| 324 | + try: |
| 325 | + jwks = await self._fetcher(self._jwks_uri, allow_private=self._allow_private) |
| 326 | + except SSRFValidationError as exc: |
| 327 | + raise SignatureVerificationError( |
| 328 | + REQUEST_SIGNATURE_JWKS_UNTRUSTED, |
| 329 | + step=7, |
| 330 | + message=f"JWKS URI failed SSRF check: {exc}", |
| 331 | + ) from exc |
| 332 | + except (httpx.HTTPError, ValueError, OSError) as exc: |
| 333 | + raise SignatureVerificationError( |
| 334 | + REQUEST_SIGNATURE_JWKS_UNAVAILABLE, |
| 335 | + step=7, |
| 336 | + message=f"JWKS fetch failed: {exc}", |
| 337 | + ) from exc |
| 338 | + self._primed = True |
| 339 | + self._cache = {jwk["kid"]: jwk for jwk in jwks.get("keys", []) if "kid" in jwk} |
| 340 | + |
| 341 | + |
| 342 | +def as_async_resolver(resolver: JwksResolver) -> AsyncJwksResolver: |
| 343 | + """Wrap a sync :class:`JwksResolver` so it satisfies :class:`AsyncJwksResolver`. |
| 344 | +
|
| 345 | + Useful for tests: pass a :class:`StaticJwksResolver` through |
| 346 | + :func:`as_async_resolver` to plug it into an async-verifier |
| 347 | + pipeline that types ``AsyncJwksResolver``. There's no real async |
| 348 | + work (just a dict lookup); the wrapper is a shape adapter. |
| 349 | + """ |
| 350 | + |
| 351 | + async def resolve(keyid: str) -> dict[str, Any] | None: |
| 352 | + return resolver(keyid) |
| 353 | + |
| 354 | + return resolve |
| 355 | + |
| 356 | + |
203 | 357 | __all__ = [ |
204 | 358 | "BLOCKED_METADATA_IPS", |
| 359 | + "AsyncCachingJwksResolver", |
| 360 | + "AsyncJwksFetcher", |
| 361 | + "AsyncJwksResolver", |
205 | 362 | "CachingJwksResolver", |
206 | 363 | "DEFAULT_JWKS_COOLDOWN_SECONDS", |
207 | 364 | "DEFAULT_JWKS_TIMEOUT_SECONDS", |
208 | 365 | "JwksFetcher", |
209 | 366 | "JwksResolver", |
210 | 367 | "SSRFValidationError", |
211 | 368 | "StaticJwksResolver", |
| 369 | + "as_async_resolver", |
| 370 | + "async_default_jwks_fetcher", |
212 | 371 | "default_jwks_fetcher", |
213 | 372 | "validate_jwks_uri", |
214 | 373 | ] |
0 commit comments