Skip to content

Ports and Services

Context

With the domain layer in place, this document defines the ports (Protocol-based interfaces) and services (business logic) following hexagonal architecture.

  • Ports define boundaries using Protocols (static duck typing)
  • Services implement business logic, depending only on domain types and ports
  • Dependencies flow inward: adapters → services → ports → domain

File structure

src/hdc_auth_proxy/
├── ports/
│   ├── __init__.py           # Re-export all Protocols
│   ├── authentication.py     # MethodAuthenticator, TokenValidator, PrincipalResolver, ApiKeyValidator, OidcProviderSettings, ClaimsMapper
│   ├── authorization.py      # PolicyRepository, ResourceRepository
│   ├── oidc.py               # PKCEStore, OIDCTokenExchanger (async ports for OIDC PKCE flow)
│   └── input.py              # Authenticator, Authorizer (input ports / use cases)
│
├── services/
│   ├── __init__.py           # Re-export service classes
│   ├── authentication.py     # TokenMethodAuthenticator, ApiKeyMethodAuthenticator, AuthenticationService
│   ├── authorization.py      # AuthorizationService
│   └── oidc.py               # OIDCFlowService (PKCE initiate + complete)
│
├── wiring.py                 # Composition root: wire_services() + wire_oidc_flow() → OIDCWiringResult
├── dev.py                    # Dev mode: build_dev_services() with pre-populated test data

Output Ports (driven side — what adapters implement)

ports/authentication.py

Protocol Method Async Input Output
MethodAuthenticator authenticate(credential) async Credential AuthenticationResult
TokenValidator validate(token) async str TokenValidationResult
PrincipalResolver resolve(claims) sync TokenClaims Principal \| None
ApiKeyValidator validate(api_key) sync str KeyValidationResult
OidcProviderSettings get_jwks_uri(), get_issuer(), get_expected_audience() sync str, str, str \| tuple[str, ...]
ClaimsMapper extract_groups(raw_claims), extract_scopes(raw_claims) sync dict frozenset[str]

ports/authorization.py

Protocol Method Async Input Output
PolicyRepository rules_for_resource(resource_uri) sync str tuple[PolicyRule, ...]
ResourceRepository find(identifier) sync ResourceIdentifier ProtectedResource \| None

ports/oidc.py

Protocol Method Async Input Output
PKCEStore store(state, code_verifier) async str, str None
PKCEStore retrieve(state) async str str \| None (atomic delete-on-read)
OIDCTokenExchanger exchange_code(code, code_verifier, redirect_uri) async str, str, str TokenExchangeResult
OIDCTokenExchanger refresh_tokens(refresh_token) async str TokenRefreshResult

These async ports are used by OIDCFlowService for the PKCE browser login flow. See ADR-0014 and ADR-0015.

Input Ports (driving side — what services expose)

ports/input.py

Protocol Method Async Input Output
Authenticator authenticate(credential) async Credential AuthenticationResult
Authorizer authorize(request) sync AccessRequest AccessResult

Services

Authentication — Strategy Pattern

Instead of a monolithic service that branches on AuthMethod, each auth method has its own MethodAuthenticator implementation with only the dependencies it needs.

                        ┌─────────────────────────────┐
                        │   AuthenticationService      │
                        │   strategies: Mapping[        │
                        │     AuthMethod,               │
                        │     MethodAuthenticator       │
                        │   ]                           │
                        └──────────┬──────────────────┘
                                   │ dispatches via Mapping
                    ┌──────────────┴──────────────┐
                    │                             │
        ┌───────────▼──────────┐     ┌────────────▼─────────┐
        │ TokenMethod          │     │ ApiKeyMethod          │
        │ Authenticator        │     │ Authenticator         │
        │                      │     │                       │
        │ deps:                │     │ deps:                 │
        │  - TokenValidator    │     │  - ApiKeyValidator    │
        │  - PrincipalResolver │     │                       │
        └──────────────────────┘     └───────────────────────┘

TokenMethodAuthenticator (OAUTH2_OIDC, JWT_BEARER):

  1. Validate token → TokenValidationResult
  2. Check expiration (domain logic)
  3. Resolve principal → Principal | None
  4. Return AuthenticationResult

ApiKeyMethodAuthenticator (API_KEY):

  1. Validate key → KeyValidationResult
  2. Return AuthenticationResult (no claims step)

AuthenticationService (dispatcher):

  • Receives Mapping[AuthMethod, MethodAuthenticator]
  • Dispatches credential.method to the right strategy
  • Returns "unsupported method" failure for unknown methods

Authorization — Default Deny

AuthorizationService:

  1. Look up resource → if public=True, ALLOW immediately
  2. Load PolicyRules matching the resource URI
  3. Evaluate rules: glob pattern + permission + role + scopes
  4. First matching rule → ALLOW; no match → DENY

Rule matching uses fnmatch for glob-style URI patterns.

OIDC PKCE Flow — OIDCFlowService

OIDCFlowService (services/oidc.py) — async service orchestrating the OIDC authorization code + PKCE S256 flow:

  • initiate(callback_uri) → generates PKCE challenge, state, nonce; stores verifier in PKCEStore; builds authorization URL
  • complete(callback, callback_uri) → retrieves verifier (atomic delete); exchanges code via OIDCTokenExchanger; returns TokenExchangeResult
  • refresh(refresh_token) → exchanges refresh token for new access/id tokens via OIDCTokenExchanger; returns TokenRefreshResult

Dependencies: PKCEStore, OIDCTokenExchanger (async ports), provider settings, client_id, scopes. No HTTP/framework dependency — testable with stubs.

See ADR-0015 for PKCE S256 and session cookie design.


Design Decisions

Decision Rationale Reference
Strategy pattern for auth methods ISP: each method has only its deps. OCP: add method = add class. No branching. ADR-0004
Result-type dataclasses (TokenValidationResult, KeyValidationResult) Avoids isinstance in services. Consistent with domain's AuthenticationResult pattern. ADR-0005
Async for I/O, sync for logic I/O-bound ports (authentication) are async def; pure-logic ports (authorization) remain sync. ADR-0006 (partially superseded by ADR-0014)
No core/ layer yet No internal plugin system needed. DDD bounded contexts suffice for now. See ADR-0002
Postel's Law in ports Output ports accept str (liberal input), return domain types (conservative output). Project typing guidelines
Provider-agnostic OIDC OidcProviderSettings + ClaimsMapper Protocols decouple JWT validation from any specific provider. ADR-0013

HTTP Adapter (Driving Side)

The HTTP adapter is the driving adapter that receives HTTP requests and orchestrates the full proxy flow. Implemented with Starlette (ADR-0011).

File structure

src/hdc_auth_proxy/adapters/http/
├── __init__.py               # Re-export create_app
├── app.py                    # create_app(ProxySettings) → Starlette
├── credential_extractor.py   # Authorization/X-API-Key → Credential | None
├── permission_mapper.py      # HTTP method → Permission
├── resource_mapper.py        # Request path → ResourceIdentifier
├── proxy_handler.py          # Orchestrates extract → authn → authz → forward
├── upstream_client.py        # httpx.AsyncClient wrapper with streaming
├── error_responses.py        # 401, 403, 502 JSONResponse builders
└── headers.py                # Hop-by-hop filter, X-Forwarded-* builder

ProxyHandler flow

Client → ProxyHandler.handle(request)
           ├── extract_credential(request) → Credential | None
           │     └── None → 401 "No credential provided"
           ├── authenticator.authenticate(credential) → AuthenticationResult
           │     └── not authenticated → 401
           ├── map_request_to_resource(path) → ResourceIdentifier
           ├── map_http_method_to_permission(method) → Permission
           ├── authorizer.authorize(AccessRequest) → AccessResult
           │     └── DENY → 403
           └── upstream_client.forward(request) → StreamingResponse
                 └── HTTPError → 502

ProxyHandler depends only on Authenticator and Authorizer Protocols — it has no knowledge of which adapters are behind them.

Composition root (wiring.py)

Adapter selection happens in wire_services(settings), the composition root that evaluates a fixed priority (ADR-0012):

Priority Condition Adapters wired
1 COGNITO_USER_POOL_ID set OIDC JWT validator (with Cognito settings + claims mapper)
2 env_state = dev In-memory with pre-populated test data (dev.py)
3 (fallback) Empty in-memory (all requests → 401)

wire_services() returns (AuthenticationService, AuthorizationService) which are passed to create_app(). This keeps the HTTP adapter decoupled from adapter selection logic.

OIDC PKCE browser login (wire_oidc_flow)

A separate wire_oidc_flow(settings) function wires the OIDC PKCE browser login flow when COGNITO_DOMAIN is set:

Condition Result
cognito.domain is empty Returns None — OIDC flow disabled
cognito.domain is set Returns AuthRouteHandler with OIDCFlowService, InMemoryPKCEStore, CognitoOIDCClient, SessionCookie

The AuthRouteHandler provides three routes added before the catch-all proxy:

Route Method Purpose
/auth/login GET Redirect to OIDC provider with PKCE S256
/auth/callback GET Exchange code for tokens, set session cookie
/auth/logout POST Clear session cookie
/auth/refresh POST Exchange refresh token for new access/id tokens

Configuration is read from PKCEConfig (PKCE_* env vars) and SessionConfig (SESSION_* env vars).

Key design choices

Choice Rationale
Starlette (not FastAPI) Transparent proxy — no OpenAPI, no validation, no Depends()
Streaming StreamingResponse + httpx.AsyncClient — no response buffering
Async authentication, sync authorization Authentication chain is async (JWKS fetch); authorization is pure in-memory logic (ADR-0014)
Pure functions Credential extraction, permission mapping, resource mapping are testable without ASGI
Composition root separate from HTTP app wire_services() is testable without starting uvicorn

Testing approach

Three-tier strategy covering unit, integration OIDC, and integration Cognito-specific tests. No Docker required — all tiers run in-process. See ADR-0016 for the evaluation of Cognito testing tools.

Tier 1 — Unit tests (pytest-httpx + stubs)

Fast, isolated tests. Stubs implement output port Protocols. pytest-httpx mocks HTTP responses for adapters that do I/O.

Test category What it verifies File
Architecture tests DDD dependency direction rules tests/test_architecture.py
Domain invariant tests Result dataclass __post_init__ validation tests/test_domain.py
OIDC domain types PKCE generation, state/nonce, authorization request, token exchange result tests/test_domain_oidc.py
Authentication service Strategy dispatch, token flow, API key flow tests/test_authentication_service.py
Authorization service Default deny, public shortcut, rule matching tests/test_authorization_service.py
OIDC flow service Initiate (URL + PKCE), complete (exchange + replay protection) tests/test_oidc_service.py
OIDC adapter (generic) JWT validation, claims mapping, principal resolution tests/adapters/oidc/test_*.py
Cognito adapter (token validator) RS256 verify, JWKS caching, Cognito-specific audience check for access tokens (ADR-0017) tests/adapters/cognito/test_token_validator.py
Cognito adapter (principal resolver) Claims → Principal mapping tests/adapters/cognito/test_principal_resolver.py
Cognito adapter (OIDC client) Token exchange: success, error, network failure tests/adapters/cognito/test_oidc_client.py
PKCE store Store, retrieve, TTL, max entries, atomic delete-on-read tests/adapters/memory/test_pkce_store.py
Session cookie HMAC sign/verify, expiry, tamper detection tests/adapters/http/test_session.py
Auth route handlers Login redirect, callback exchange, logout cookie clear, refresh token exchange tests/adapters/http/test_auth_routes.py
Credential extractor (cookies) Session cookie → Bearer credential extraction tests/adapters/http/test_credential_extractor.py
HTTP adapter (pure functions) Credential extraction, permission mapping, headers tests/adapters/http/test_*.py
HTTP adapter (proxy handler) Full flow: authn → authz → proxy with in-memory adapters tests/adapters/http/test_proxy_handler.py
Upstream client Streaming, query string, error handling (asyncio + trio) tests/adapters/http/test_upstream_client.py
Wiring / composition root Adapter selection: Cognito > dev > empty + OIDC flow wiring tests/test_wiring.py
Dev mode Pre-populated in-memory adapters, auth/authz scenarios tests/test_dev_mode.py
In-memory integration Full stack: in-memory adapters → services → authn + authz tests/test_integration_inmemory.py

Tier 2 — Integration OIDC (pytest-iam)

Tests the full OIDC PKCE S256 flow against a real OIDC provider (Canaille + Authlib) running in-thread. Validates standard compliance independently from Cognito.

Test category What it verifies File
Full PKCE flow Authorize → code → token exchange → userinfo tests/test_integration_oidc.py
Wrong verifier rejected Provider rejects tampered code_verifier tests/test_integration_oidc.py
Replay prevention Same state used twice → rejected tests/test_integration_oidc.py

Tier 3 — Integration Cognito (moto)

Tests Cognito-specific JWT behavior using moto (in-process AWS mock). Verifies that our adapters work with Cognito's specific token format, issuer convention, and claim names.

Test category What it verifies File
Token issuer format https://cognito-idp.{region}.amazonaws.com/{pool_id} matches CognitoSettings tests/test_integration_cognito.py
JWKS verification Tokens from moto verifiable via JWKS endpoint tests/test_integration_cognito.py
cognito:groups extraction CognitoClaimsMapper extracts groups from moto-generated tokens tests/test_integration_cognito.py
End-to-end with Cognito tokens AdminInitiateAuth → token → OidcTokenValidatorCognitoPrincipalResolver tests/test_integration_cognito.py

Tier 4 — E2E Cognito OIDC (magnito + testcontainers)

Tests the full Cognito OIDC PKCE flow against magnito (Docker-based Cognito emulator). Requires Docker. Marked @pytest.mark.e2e, runs as a separate CI job.

Test category What it verifies File
Full PKCE S256 login flow Authorize → code → token exchange with PKCE verifier tests/e2e/test_cognito_oidc.py
Wrong verifier rejected magnito rejects tampered code_verifier tests/e2e/test_cognito_oidc.py
Refresh token flow Exchange refresh token for new access/id tokens tests/e2e/test_cognito_oidc.py
Token verification via magnito JWKS CognitoTokenValidator verifies magnito-signed ID tokens tests/e2e/test_cognito_oidc.py
Session cookie roundtrip Tokens stored in and retrieved from HMAC-signed cookie tests/e2e/test_cognito_oidc.py
Invalid refresh token rejected magnito rejects invalid refresh token tests/e2e/test_cognito_oidc.py