13. Provider-agnostic JWT validation
Date: 2026-03-16
Status
Accepted
Context
The proxy's JWT validation was initially built around AWS Cognito as the sole identity provider. While the service layer and ports were already provider-agnostic (thanks to TokenValidator and PrincipalResolver Protocols), the concrete adapter implementations had Cognito-specific coupling:
CognitoTokenValidatordepended onCognitoSettingsfor issuer/audience/JWKSCognitoPrincipalResolverhard-codedIdentityProvider.COGNITOand Cognito claim conventionsJwtClaimsDtohad acognito:groupsalias fieldwiring.pyimported Cognito-specific adapters directly
To support additional OIDC providers (Auth0, Okta, generic OIDC) without duplicating validation logic, we need provider-agnostic abstractions at the adapter layer.
Decision
We introduce two new Protocols and a set of generic OIDC adapters that extract the provider-agnostic logic from the Cognito-specific implementations.
New Protocols (ports/authentication.py)
OidcProviderSettings — abstracts provider configuration:
class OidcProviderSettings(Protocol):
def get_jwks_uri(self) -> str: ...
def get_issuer(self) -> str: ...
def get_expected_audience(self) -> str | tuple[str, ...]: ...
CognitoSettings satisfies this Protocol by adding get_expected_audience() (returns client_id). Any new provider settings class just needs these three methods.
ClaimsMapper — normalizes provider-specific claims:
class ClaimsMapper(Protocol):
def extract_groups(self, raw_claims: dict[str, object]) -> frozenset[str]: ...
def extract_scopes(self, raw_claims: dict[str, object]) -> frozenset[str]: ...
Different providers encode groups in different claim names (cognito:groups for Cognito, groups for standard OIDC). ClaimsMapper abstracts this.
Generic OIDC adapters (adapters/oidc/)
| Class | File | Responsibility |
|---|---|---|
OidcTokenValidator |
token_validator.py |
JWKS fetch + cache, RS256 verify, issuer/audience/expiration check |
OidcPrincipalResolver |
principal_resolver.py |
Claims → Principal with configurable IdentityProvider |
CognitoClaimsMapper |
claims_mapper.py |
Extract groups from cognito:groups, scopes from scope |
StandardClaimsMapper |
claims_mapper.py |
Extract groups from groups, scopes from scope |
OidcTokenValidator accepts OidcProviderSettings and ClaimsMapper as constructor dependencies. The audience validation supports flexible matching:
- Token
audas string, expected as string → exact match - Token
audas array, expected as string → expected in array - Expected as tuple → any overlap with token
aud
Cognito adapters as thin wrappers (adapters/cognito/)
The existing CognitoTokenValidator and CognitoPrincipalResolver become thin wrappers that delegate to the generic implementations:
class CognitoTokenValidator:
def __init__(self, settings):
self._delegate = OidcTokenValidator(
settings=settings,
claims_mapper=CognitoClaimsMapper(),
)
async def validate(self, token):
return await self._delegate.validate(token)
This preserves backward compatibility for code that imports CognitoTokenValidator directly.
Composition root (wiring.py)
Updated to use generic OIDC adapters directly when Cognito is configured:
token_validator = OidcTokenValidator(
settings=settings.cognito, # satisfies OidcProviderSettings
claims_mapper=CognitoClaimsMapper(),
) # uses httpx.AsyncClient internally (ADR-0014)
principal_resolver = OidcPrincipalResolver(provider=IdentityProvider.COGNITO)
Adding a new provider (e.g., Auth0) requires only:
- A settings class satisfying
OidcProviderSettings - A
ClaimsMapperimplementation (if claim names differ from standard OIDC) - A new condition in
wire_services()— no changes to the generic adapters
JwtClaimsDto refactored
The cognito_groups field was removed. Provider-specific claim extraction is now handled by ClaimsMapper, not by the DTO. The DTO remains as a generic OIDC claims parser with extra="allow" for custom fields.
File structure
src/hdc_auth_proxy/adapters/
├── oidc/ # Provider-agnostic OIDC
│ ├── __init__.py
│ ├── token_validator.py # OidcTokenValidator
│ ├── principal_resolver.py # OidcPrincipalResolver
│ └── claims_mapper.py # CognitoClaimsMapper, StandardClaimsMapper
│
├── cognito/ # Cognito-specific (thin wrappers)
│ ├── __init__.py
│ ├── token_validator.py # CognitoTokenValidator → delegates
│ └── principal_resolver.py # CognitoPrincipalResolver → delegates
Alternatives considered
Parameterize CognitoTokenValidator without new abstractions
Add constructor parameters for issuer, audience, groups claim name. Simpler but doesn't scale — each new provider adds more parameters, and the class name becomes misleading.
Abstract base class for token validators
An ABC with abstract methods. Rejected per project guidelines: Protocols are preferred for public APIs and consumer interfaces (static duck typing). ABCs are reserved for internal plugin systems.
Keep provider-specific logic inline per adapter
Duplicate validation logic in each provider's adapter. Violates DRY and makes it harder to keep behavior consistent across providers.
Consequences
- Adding a new OIDC provider requires ~3 small classes (settings, claims mapper, wiring condition) — no changes to core validation logic
CognitoSettingsnow satisfiesOidcProviderSettingsProtocol via structural typing- All 16 existing Cognito adapter tests pass without modification (backward compatible)
- 32 new OIDC adapter tests cover the generic implementations independently
JwtClaimsDtois provider-agnostic;to_domain()accepts injected groups/scopes- The
wiring.pycomposition root uses generic OIDC adapters directly, reducing Cognito coupling - Future OIDC PKCE flow (Phase 4) can reuse
OidcTokenValidatorfor id_token validation
References
- ADR-0004 Strategy Pattern — Authentication strategy pattern
- ADR-0010 Cognito JWT Adapter — Original Cognito adapter
- ADR-0012 Environment-based Adapter Selection — Wiring priority
- ADR-0014 Async Ports for OIDC — Async refactoring of TokenValidator
- Issue #3 — Create abstractions for JWT token validation classes