Skip to content

feat(signing): async revocation-list + JWKS fetchers#202

Merged
bokelley merged 1 commit intomainfrom
bokelley/signing-async-fetcher
Apr 19, 2026
Merged

feat(signing): async revocation-list + JWKS fetchers#202
bokelley merged 1 commit intomainfrom
bokelley/signing-async-fetcher

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

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.run bridge. Addresses the dx-expert's top DX callout on #201.

Shape

from adcp.signing import (
    AsyncCachingJwksResolver,
    AsyncCachingRevocationChecker,
)

jwks = AsyncCachingJwksResolver(
    jwks_uri="https://gov.example.com/.well-known/jwks.json"
)
checker = AsyncCachingRevocationChecker.from_issuer_origin(
    "https://gov.example.com",
    jwks_resolver=jwks,
)
await checker.aprime()  # optional fail-fast at startup

# Later, in a request handler:
if await checker(kid):
    raise HTTPException(401, "key revoked")

New public API

Name Kind Role
AsyncJwksResolver / AsyncJwksFetcher Protocol async counterparts to existing Protocols
AsyncCachingJwksResolver class asyncio.Lock-serialized JWKS refresh
async_default_jwks_fetcher function httpx.AsyncClient-backed default
as_async_resolver(sync) adapter wrap a StaticJwksResolver for async pipelines
AsyncRevocationListFetcher Protocol async revocation fetcher
async_default_revocation_list_fetcher function httpx.AsyncClient default
AsyncCachingRevocationChecker class parallel to sync checker; aprime(), awaitable __call__ + is_jti_revoked, from_issuer_origin classmethod
averify_detached_jws / averify_jws_document function async JWS verify, takes an AsyncJwksResolver

Refactor

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
  • _CheckerState mixin — mutable cache state + _handle_not_modified / _commit / _grace_seconds

Preserves 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:

# Category Fix
1 Security (MUST) Eager asyncio.Lock() in __init__ — lazy init was racy
2 Security Cancellation-safe _last_refresh_attempt rollback
3 Correctness Recompute wall_clock / clock inside the lock
4 Refactor _CheckerState mixin replaces duplicated methods
5 Types asyncio.Lock instead of `Any
6 Style import asyncio at module top, not in hot path
7 API averify_detached_jws + verify_detached_jws both exported
8 Docs Naming-convention note in module docstrings (Async / async_ / a split)

Not in scope (follow-ups)

  • Async quickstart doc (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.
  • Cross-event-loop detection — current docs say "instances are per-loop; don't share across asyncio.run boundaries". Detection would be nice but not load-bearing.
  • Async verifier (averify_request_signature) — the sync verify_request_signature works 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 skipped
  • ruff check src/adcp/signing/ tests/conformance/signing/ — clean
  • mypy src/adcp/signing/ — clean (16 source files)
  • New coverage:
    • 20-task concurrent first-miss → 1 refresh (regression test for the lazy-lock race)
    • Cancellation rolls back cooldown attempt
    • Native async e2e via Starlette + httpx.AsyncClient (no asyncio.run)
    • Async JWKS lock serialization under concurrent misses

🤖 Generated with Claude Code

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>
@bokelley bokelley merged commit 55a8fd4 into main Apr 19, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant