Skip to content

Commit 55a8fd4

Browse files
authored
Merge pull request #202 from adcontextprotocol/bokelley/signing-async-fetcher
feat(signing): async revocation-list + JWKS fetchers
2 parents 249b79d + 1c2e676 commit 55a8fd4

5 files changed

Lines changed: 1448 additions & 154 deletions

File tree

src/adcp/signing/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,15 @@
6363
SignatureVerificationError,
6464
)
6565
from adcp.signing.jwks import (
66+
AsyncCachingJwksResolver,
67+
AsyncJwksFetcher,
68+
AsyncJwksResolver,
6669
CachingJwksResolver,
6770
JwksResolver,
6871
SSRFValidationError,
6972
StaticJwksResolver,
73+
as_async_resolver,
74+
async_default_jwks_fetcher,
7075
default_jwks_fetcher,
7176
validate_jwks_uri,
7277
)
@@ -75,6 +80,9 @@
7580
JwsMalformedError,
7681
JwsSignatureInvalidError,
7782
JwsUnknownKeyError,
83+
averify_detached_jws,
84+
averify_jws_document,
85+
verify_detached_jws,
7886
verify_jws_document,
7987
)
8088
from adcp.signing.middleware import (
@@ -87,12 +95,15 @@
8795
from adcp.signing.revocation_fetcher import (
8896
DEFAULT_GRACE_MULTIPLIER,
8997
REVOCATION_LIST_TYP,
98+
AsyncCachingRevocationChecker,
99+
AsyncRevocationListFetcher,
90100
CachingRevocationChecker,
91101
FetchResult,
92102
RevocationListFetcher,
93103
RevocationListFetchError,
94104
RevocationListFreshnessError,
95105
RevocationListParseError,
106+
async_default_revocation_list_fetcher,
96107
default_revocation_list_fetcher,
97108
)
98109
from adcp.signing.signer import (
@@ -110,6 +121,11 @@
110121
"ALG_ED25519",
111122
"ALG_ES256",
112123
"ALLOWED_ALGS",
124+
"AsyncCachingJwksResolver",
125+
"AsyncCachingRevocationChecker",
126+
"AsyncJwksFetcher",
127+
"AsyncJwksResolver",
128+
"AsyncRevocationListFetcher",
113129
"CachingJwksResolver",
114130
"CachingRevocationChecker",
115131
"DEFAULT_EXPIRES_IN_SECONDS",
@@ -163,6 +179,11 @@
163179
"VerifierCapability",
164180
"VerifyOptions",
165181
"alg_for_jwk",
182+
"as_async_resolver",
183+
"async_default_jwks_fetcher",
184+
"async_default_revocation_list_fetcher",
185+
"averify_detached_jws",
186+
"averify_jws_document",
166187
"b64url_decode",
167188
"b64url_encode",
168189
"build_signature_base",
@@ -182,6 +203,7 @@
182203
"sign_signature_base",
183204
"unauthorized_response_headers",
184205
"validate_jwks_uri",
206+
"verify_detached_jws",
185207
"verify_flask_request",
186208
"verify_jws_document",
187209
"verify_request_signature",

src/adcp/signing/jwks.py

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,19 @@
1212
tracked in #190 and not implemented here — the current design is vulnerable to
1313
a TOCTOU where DNS resolves to an allowed IP during validation and a blocked
1414
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.
1523
"""
1624

1725
from __future__ import annotations
1826

27+
import asyncio
1928
import ipaddress
2029
import socket
2130
import time
@@ -60,18 +69,43 @@ class JwksFetcher(Protocol):
6069
def __call__(self, uri: str, *, allow_private: bool = False) -> dict[str, Any]: ...
6170

6271

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+
6380
class JwksResolver(Protocol):
6481
"""Resolves a keyid to a JWK, or returns None if unknown.
6582
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
6885
:class:`StaticJwksResolver` (in-memory, for tests) and
6986
:class:`CachingJwksResolver` (fetches + caches from a URI).
87+
88+
Async callers use :class:`AsyncJwksResolver` instead.
7089
"""
7190

7291
def __call__(self, keyid: str) -> dict[str, Any] | None: ...
7392

7493

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+
75109
def validate_jwks_uri(uri: str, *, allow_private: bool = False) -> None:
76110
"""Raise SSRFValidationError if `uri` resolves to a blocked IP or has a bad scheme."""
77111
parts = urlsplit(uri)
@@ -200,15 +234,140 @@ def __call__(self, keyid: str) -> dict[str, Any] | None:
200234
return self._keys.get(keyid)
201235

202236

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+
203357
__all__ = [
204358
"BLOCKED_METADATA_IPS",
359+
"AsyncCachingJwksResolver",
360+
"AsyncJwksFetcher",
361+
"AsyncJwksResolver",
205362
"CachingJwksResolver",
206363
"DEFAULT_JWKS_COOLDOWN_SECONDS",
207364
"DEFAULT_JWKS_TIMEOUT_SECONDS",
208365
"JwksFetcher",
209366
"JwksResolver",
210367
"SSRFValidationError",
211368
"StaticJwksResolver",
369+
"as_async_resolver",
370+
"async_default_jwks_fetcher",
212371
"default_jwks_fetcher",
213372
"validate_jwks_uri",
214373
]

0 commit comments

Comments
 (0)