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):
- Validate token →
TokenValidationResult - Check expiration (domain logic)
- Resolve principal →
Principal | None - Return
AuthenticationResult
ApiKeyMethodAuthenticator (API_KEY):
- Validate key →
KeyValidationResult - Return
AuthenticationResult(no claims step)
AuthenticationService (dispatcher):
- Receives
Mapping[AuthMethod, MethodAuthenticator] - Dispatches
credential.methodto the right strategy - Returns "unsupported method" failure for unknown methods
Authorization — Default Deny
AuthorizationService:
- Look up resource → if
public=True, ALLOW immediately - Load
PolicyRules matching the resource URI - Evaluate rules: glob pattern + permission + role + scopes
- 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 inPKCEStore; builds authorization URLcomplete(callback, callback_uri)→ retrieves verifier (atomic delete); exchanges code viaOIDCTokenExchanger; returnsTokenExchangeResultrefresh(refresh_token)→ exchanges refresh token for new access/id tokens viaOIDCTokenExchanger; returnsTokenRefreshResult
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 → OidcTokenValidator → CognitoPrincipalResolver |
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 |