feat(signing): async revocation-list + JWKS fetchers#202
Merged
Conversation
Adds async counterparts to the sync types that landed in PR #201 so verifiers running on an asyncio event loop (FastAPI, Starlette, etc.) don't need to bridge through ``asyncio.run`` or block the loop on revocation/JWKS fetches. Addresses the dx-expert's top DX callout on PR #201. New public surface (``adcp.signing``) ------------------------------------- * ``AsyncJwksResolver`` / ``AsyncJwksFetcher`` Protocols * ``AsyncCachingJwksResolver`` with ``asyncio.Lock``-serialized refreshes * ``async_default_jwks_fetcher`` using ``httpx.AsyncClient`` * ``as_async_resolver(sync)`` adapter so ``StaticJwksResolver`` can plug into async pipelines * ``AsyncRevocationListFetcher`` Protocol * ``async_default_revocation_list_fetcher`` * ``AsyncCachingRevocationChecker`` — mirrors ``CachingRevocationChecker`` with awaitable ``__call__``, ``aprime()``, awaitable ``is_jti_revoked``, ``from_issuer_origin`` classmethod. ``asyncio.Lock`` serializes concurrent refreshes so N parallel first-miss tasks fire one fetch. * ``averify_detached_jws`` / ``averify_jws_document`` Refactor -------- Sync and async paths share: * ``_post_jws_validation`` — schema + clock-skew + replay check * ``_slide_next_update`` — 304 response handling * ``_polling_interval_seconds`` — clamped interval math * ``_build_fetch_headers`` / ``_fetch_result_from_response`` — HTTP glue * ``_validate_header`` / ``_verify_signature_and_decode_payload`` — JWS pipeline split at the resolver call * ``_CheckerState`` mixin — cache state + ``_handle_not_modified`` / ``_commit`` / ``_grace_seconds`` methods inherited by both checkers Preserves sync behavior byte-for-byte; sync test suite unchanged at 219. Defense-in-depth (from expert re-review) ---------------------------------------- * Eager ``asyncio.Lock()`` construction in ``__init__``. Lazy init was racy — two tasks could each see ``self._lock is None`` and construct separate locks. Python 3.10+ no longer requires a running loop for ``asyncio.Lock()``. * Cancellation-safe ``_last_refresh_attempt`` — rolls back on ``CancelledError`` so a cancelled task doesn't burn the 60s cooldown for the next legitimate caller (DoS-amplification defense). * Clock reads recomputed inside the lock — the pre-lock reads could be stale after a slow refresh. Tests (+20) ----------- ``test_async_revocation.py`` — 20 tests mirroring sync coverage plus: * ``test_concurrent_first_calls_share_one_refresh`` — 20 tasks → exactly 1 refresh (regression test for the lazy-lock race) * ``test_cancellation_rolls_back_cooldown_attempt`` — pins the cancellation-safety invariant * ``test_async_e2e_asgi_round_trip`` — native Starlette + ``httpx.AsyncClient`` e2e, NO ``asyncio.run`` bridge (the whole point of the async path) * ``test_async_caching_jwks_resolver_handles_concurrent_misses`` — async JWKS lock serialization 239 signing tests pass, ruff + mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds native-async counterparts to the sync revocation-list and JWKS machinery from PR #201, so verifiers on an asyncio event loop (FastAPI, Starlette) don't block the loop or need an
asyncio.runbridge. Addresses the dx-expert's top DX callout on #201.Shape
New public API
AsyncJwksResolver/AsyncJwksFetcherAsyncCachingJwksResolverasyncio.Lock-serialized JWKS refreshasync_default_jwks_fetcherhttpx.AsyncClient-backed defaultas_async_resolver(sync)StaticJwksResolverfor async pipelinesAsyncRevocationListFetcherasync_default_revocation_list_fetcherhttpx.AsyncClientdefaultAsyncCachingRevocationCheckeraprime(), awaitable__call__+is_jti_revoked,from_issuer_originclassmethodaverify_detached_jws/averify_jws_documentAsyncJwksResolverRefactor
Sync and async paths share:
_post_jws_validation— schema + clock-skew + replay check_slide_next_update— 304 handling_polling_interval_seconds— interval math_build_fetch_headers/_fetch_result_from_response— HTTP glue_validate_header/_verify_signature_and_decode_payload— JWS pipeline split at the resolver call_CheckerStatemixin — mutable cache state +_handle_not_modified/_commit/_grace_secondsPreserves sync behavior byte-for-byte; sync test suite unchanged.
Review
Reviewed by code-reviewer, security-reviewer, and dx-expert subagents. One MUST FIX, several SHOULD FIXes applied in this PR:
asyncio.Lock()in__init__— lazy init was racy_last_refresh_attemptrollbackwall_clock/clockinside the lock_CheckerStatemixin replaces duplicated methodsasyncio.Lockinstead of `Anyimport asyncioat module top, not in hot pathaverify_detached_jws+verify_detached_jwsboth exportedAsync/async_/asplit)Not in scope (follow-ups)
docs/signing-async.md) — dx-expert's chore(main): release 0.1.0 #1 remaining item. Separate docs PR; the test file is already a working template in the meantime.asyncio.runboundaries". Detection would be nice but not load-bearing.averify_request_signature) — the syncverify_request_signatureworks fine for now; a real async verifier lands once we see how callers want to wire it.Test plan
pytest tests/conformance/signing/ -q— 239 passed, 1 skippedruff check src/adcp/signing/ tests/conformance/signing/— cleanmypy src/adcp/signing/— clean (16 source files)httpx.AsyncClient(noasyncio.run)🤖 Generated with Claude Code