Skip to content

14. Async ports for OIDC and token validation

Date: 2026-03-17

Status

Accepted

Context

The proxy runs on Starlette (async ASGI). The original design (ADR-0006) used sync-first Protocols because domain services complete in microseconds and only HTTP forwarding was async.

The OIDC PKCE flow introduces new ports that require async I/O (PKCEStore, OIDCTokenExchanger). During implementation, an analysis of the full request path revealed that the existing TokenValidator also performs blocking I/O inside the async event loop.

Event loop blocking analysis

The ProxyHandler.handle() async handler calls sync code that can block:

[async] ProxyHandler.handle(request)
  ├── extract_credential()                     # pure, ~μs ✅
  ├── authenticator.authenticate(credential)   # sync ⚠️
  │     └── TokenMethodAuthenticator.authenticate()
  │           ├── token_validator.validate(token)
  │           │     ├── _find_key(kid)
  │           │     │     └── _fetch_jwks()     # ❌ httpx.Client.get() BLOCKING
  │           │     ├── jwt.decode()             # CPU ~1ms ✅
  │           │     └── claims checks            # pure ~μs ✅
  │           └── principal_resolver.resolve()   # pure ~μs ✅
  ├── authorizer.authorize()                   # in-memory ~μs ✅
  └── [async] upstream_client.forward()        # async ✅

Blocking points identified

Operation Type Duration Frequency Blocks event loop?
_fetch_jwks() via httpx.Client Sync network I/O 50-200ms Startup + key rotation Yes
jwt.decode() RS256 CPU-bound (crypto) ~0.5-1ms Every request No (< 1ms)
All other operations In-memory ~μs Every request No

Real-world blocking scenarios

Scenario Impact Probability
Startup: first JWT after boot All concurrent requests blocked ~100ms Every restart
Key rotation: unknown kid One request triggers JWKS refresh ~100ms ~1/week
JWKS endpoint down Thread blocked for httpx timeout (default 5s) Rare but catastrophic
Burst after deploy First fetch blocks, subsequent requests queue behind GIL Every deploy

Options evaluated

Option Description Pro Contra
A Wrap sync calls in asyncio.to_thread() Minimal code change Masks the problem, thread pool overhead on every request
B Make TokenValidator and auth chain async Eliminates blocking at the root Rewrites Protocols, adapters, services, tests
C Pre-fetch JWKS at startup + to_thread for refresh Covers startup + rare refresh Two mechanisms, accidental complexity
D Accept the risk Zero change Real blocking in production

Decision

Option B: make the authentication chain async.

The project is in active development with no production deployments. There is no backward compatibility constraint. Making the ports async now avoids accumulating technical debt.

What becomes async

Component Change Reason
TokenValidator.validate() defasync def JWKS fetch is network I/O
MethodAuthenticator.authenticate() defasync def Calls TokenValidator.validate()
Authenticator.authenticate() (input port) defasync def Called from async ProxyHandler.handle()
OidcTokenValidator httpx.Clienthttpx.AsyncClient Async JWKS fetch
TokenMethodAuthenticator defasync def Awaits token validator
ApiKeyMethodAuthenticator defasync def Conforms to MethodAuthenticator Protocol
AuthenticationService defasync def Awaits strategy
InMemoryTokenValidator defasync def Conforms to Protocol
CognitoTokenValidator defasync def Delegates to async OidcTokenValidator

What stays sync

Component Reason
PrincipalResolver.resolve() Pure logic, no I/O, ~μs
ApiKeyValidator.validate() In-memory hash lookup, no I/O
AuthorizationService.authorize() In-memory policy evaluation, no I/O
SessionCookie.sign()/verify() HMAC-SHA256, ~μs

New OIDC-specific async ports

Two new ports introduced for the PKCE flow, async from the start:

class PKCEStore(Protocol):
    async def store(self, state: str, code_verifier: str) -> None: ...
    async def retrieve(self, state: str) -> str | None: ...

class OIDCTokenExchanger(Protocol):
    async def exchange_code(
        self, code: str, code_verifier: str, redirect_uri: str,
    ) -> TokenExchangeResult: ...

Consequences

  • OidcTokenValidator uses httpx.AsyncClient — JWKS fetch never blocks the event loop
  • ProxyHandler.handle() awaits authenticator.authenticate() — clean async chain
  • InMemoryTokenValidator and InMemoryPrincipalResolver work unchanged (trivially async)
  • All existing tests updated to use async def and pytest.mark.anyio
  • PrincipalResolver and ApiKeyValidator remain sync — no unnecessary async overhead
  • ADR-0006 (sync-first) is superseded for I/O-bound ports; sync-first still applies to pure domain logic

References