Skip to content

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:

  • CognitoTokenValidator depended on CognitoSettings for issuer/audience/JWKS
  • CognitoPrincipalResolver hard-coded IdentityProvider.COGNITO and Cognito claim conventions
  • JwtClaimsDto had a cognito:groups alias field
  • wiring.py imported 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 aud as string, expected as string → exact match
  • Token aud as 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:

  1. A settings class satisfying OidcProviderSettings
  2. A ClaimsMapper implementation (if claim names differ from standard OIDC)
  3. 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
  • CognitoSettings now satisfies OidcProviderSettings Protocol via structural typing
  • All 16 existing Cognito adapter tests pass without modification (backward compatible)
  • 32 new OIDC adapter tests cover the generic implementations independently
  • JwtClaimsDto is provider-agnostic; to_domain() accepts injected groups/scopes
  • The wiring.py composition root uses generic OIDC adapters directly, reducing Cognito coupling
  • Future OIDC PKCE flow (Phase 4) can reuse OidcTokenValidator for id_token validation

References