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() |
def → async def |
JWKS fetch is network I/O |
MethodAuthenticator.authenticate() |
def → async def |
Calls TokenValidator.validate() |
Authenticator.authenticate() (input port) |
def → async def |
Called from async ProxyHandler.handle() |
OidcTokenValidator |
httpx.Client → httpx.AsyncClient |
Async JWKS fetch |
TokenMethodAuthenticator |
def → async def |
Awaits token validator |
ApiKeyMethodAuthenticator |
def → async def |
Conforms to MethodAuthenticator Protocol |
AuthenticationService |
def → async def |
Awaits strategy |
InMemoryTokenValidator |
def → async def |
Conforms to Protocol |
CognitoTokenValidator |
def → async 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
OidcTokenValidatoruseshttpx.AsyncClient— JWKS fetch never blocks the event loopProxyHandler.handle()awaitsauthenticator.authenticate()— clean async chainInMemoryTokenValidatorandInMemoryPrincipalResolverwork unchanged (trivially async)- All existing tests updated to use
async defandpytest.mark.anyio PrincipalResolverandApiKeyValidatorremain 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
- ADR-0006 Sync-first Protocols — original sync-first decision (partially superseded)
- ADR-0013 Provider-agnostic JWT Validation — OIDC adapter architecture