Authentication Patterns
Principal model, authentication methods as adapters (password, OTP, OAuth), access/refresh tokens, and OAuth 2.0 / OIDC claims.
Purpose: How authentication is modelled, the adapter pattern for multiple authentication methods, and how successful authentication produces the identity claims that populate ActorContext.
Authentication vs Authorization
These are two distinct concerns that must not be conflated.
Authentication answers: Who is this caller?
- Verifying a password, validating a JWT, exchanging an OAuth code — these all authenticate.
- The output is identity: a set of claims about the principal.
Authorization answers: What is this caller allowed to do?
- Checking roles, scopes, payment status, account ownership — these are authorization checks.
- Authorization operates on the
ActorContextproduced by authentication.
The application and domain layers never perform authentication. They receive an ActorContext and apply business rules. Authentication is exclusively an infrastructure and API layer concern.
The Principal
The principal is the authenticated entity — the who behind a request. In OAuth 2.0 / OIDC terminology, the principal is identified by the sub (subject) claim in a token.
In the domain model, the principal is the Account aggregate. An account:
- Has one or more authentication methods (ways of proving identity)
- Stores credentials for each method
- Is identified by a stable, immutable
account_id(the subject)
A principal's authorizations (roles, scopes, permissions) are separate from their authentication methods. Adding a new authentication method does not change what the principal is authorized to do.
Authentication Methods & Credentials
An account can authenticate via multiple methods simultaneously. Each method is an adapter to the authentication port — they all produce the same output (identity claims) via different mechanisms.
Password Authentication
Credential stored: PasswordCredential — bcrypt hash, never plaintext.
Flow:
- Principal submits identifier (email or phone) + password
- Adapter fetches account, verifies password hash (constant-time comparison)
- On success: issues access token + refresh token
OTP (One-Time Password) Authentication
Credential stored: Transient OTPChallenge — hashed OTP code, expires in minutes.
Flow:
- Principal submits identifier; system generates OTP, hashes it, sends out-of-band (email/SMS)
- Principal submits OTP code
- Adapter verifies code hash (constant-time), checks expiry and attempt count
- On success: marks authentication method as verified, issues access token + refresh token
Used for: password-less login, email/phone verification, password reset.
OAuth 2.0 (Authorization Code Flow)
Credential stored: OAuthCredential — provider_user_id (immutable link to provider identity), linked at timestamp.
Flow:
- Principal initiates OAuth; system generates authorization URL with state parameter
- Principal authenticates at provider (Google, Facebook, etc.) and grants consent
- Provider redirects with authorization code; system exchanges code for provider access token
- System fetches principal's identity from provider (email, name, provider user ID)
- System finds or creates account linked to
provider_user_id - Issues access token + refresh token
Note: The provider's access token is used only to fetch identity — it is not stored or forwarded. The system issues its own tokens.
Webhook / Service-to-Service Authentication
Credential: Shared secret, validated via HMAC signature on the request payload.
Flow:
- External service sends request with signature header
- Adapter computes expected HMAC, compares with constant-time comparison
- On success: populates
ActorContextwithactor_type = "system"
Tokens (Credentials Issued by the System)
After successful authentication, the system issues two tokens:
Access Token
- Format: JWT (RFC 7519)
- Expiry: Short-lived (15 minutes typical)
- Purpose: Proves the principal is authenticated; carries claims for authorization decisions
- Not stored — validated by signature verification alone
Standard claims:
{
"iss": "https://your-service.com", # Issuer — identifies token origin
"aud": "your-service-api", # Audience — intended recipient
"sub": "<account_id>", # Subject — the principal (stable, immutable)
"iat": 1700000000, # Issued at (Unix timestamp)
"exp": 1700000900, # Expiry (Unix timestamp)
}Additional claims (domain-specific):
{
"account_type": "personal" | "admin",
"roles": ["admin"],
"authenticated_methods": ["email", "google"],
}Required claims: iss and aud are mandatory. Without iss, tokens cannot be attributed to their issuing service. Without aud, tokens accepted by one service may be replayed against another.
Refresh Token
- Format: JWT
- Expiry: Long-lived (7 days typical)
- Purpose: Exchanged for a new access token without re-authentication
- Stored (hashed) in the database to support revocation
- Revocable: Logout, suspicious activity, or explicit revocation invalidates the stored hash
The Authentication Port
The authentication adapters share a common interface. Each adapter:
- Accepts credential material (token string, password + identifier, HMAC signature, OAuth code)
- Validates the credential
- Returns identity claims or raises an
AuthenticationError
class IAuthenticationAdapter(Protocol):
"""Validates a credential and returns identity claims."""
async def authenticate(self, credential: CredentialMaterial) -> IdentityClaims:
"""
Raises AuthenticationError if the credential is invalid.
Returns IdentityClaims on success.
"""
...Concrete adapters: JWTAccessTokenAdapter, PasswordAdapter, OTPAdapter, OAuthAdapter, WebhookHMACAdapter.
From Claims to ActorContext
Authentication produces claims. Middleware (or a request-scoped dependency) maps those claims into ActorContext, which is passed to every application handler:
# Middleware populates ActorContext from validated JWT claims
actor = ActorContext(
actor_id=claims["sub"],
actor_type=claims.get("account_type", "user"),
roles=tuple(claims.get("roles", [])),
account_ids=(claims["sub"],),
)
request.state.ctx["actor"] = actorThe ActorContext is the only authentication artifact the application layer ever sees. Handlers never inspect tokens, credentials, or raw claims directly.
OIDC Compliance Notes
The current implementation follows OAuth 2.0 patterns but is not a full OpenID Connect implementation. Known pragmatic deviations:
| Standard | Status | Note |
|---|---|---|
iss claim | Required — implement | Token origin must be attributable |
aud claim | Required — implement | Prevents token replay across services |
nonce claim | Optional | Relevant only for OIDC browser flows |
| Identity Token (ID Token) | Not implemented | Access token carries identity claims instead |
| Scopes | Not implemented | Role-based authorization used instead |
| PKCE | Not implemented | State-only CSRF; acceptable for SPA/mobile |
| Token introspection endpoint | Not implemented | Stateless JWT validation used instead |
Related Documents
- Authorization Strategies — what happens after authentication
- Actor Context — the identity representation passed to application handlers
- Permission Models — roles and permission checks
Reference implementation: application/authentication/ and infrastructure/authentication/ in any service with authentication.
Framework implementations
Abstract reference: 15-security/
Authentication is handled in JWTAuthMiddleware. Authorisation is handled via Depends() in route definitions. Application and domain layers are auth-unaware — they receive an ActorContext and apply business rules.
JWT Service Interface (application/authentication/jwt_service.py)
from abc import ABC, abstractmethod
from uuid import UUID
class JWTService(ABC):
@abstractmethod
def generate_token(self, account_id: UUID, account_type: str, roles: list[str]) -> str: ...
@abstractmethod
def refresh_token(self, token: str) -> str: ...
@abstractmethod
def validate_token(self, token: str) -> bool: ...
@abstractmethod
def extract_claims(self, token: str) -> dict: ...JWT Implementation (infrastructure/authentication/jwt_token.py)
import jwt
from datetime import datetime, timedelta, timezone
from uuid import UUID
from application.authentication.jwt_service import JWTService
class PyJWTService(JWTService):
def __init__(self, secret: str, algorithm: str = "HS256",
access_expire_minutes: int = 1440,
issuer: str = "",
audience: str = ""):
self._secret = secret
self._algorithm = algorithm
self._access_expire_minutes = access_expire_minutes
self._issuer = issuer # e.g. "https://api.your-service.com"
self._audience = audience # e.g. "your-service-api"
def generate_token(self, account_id: UUID, account_type: str, roles: list[str]) -> str:
now = datetime.now(timezone.utc)
payload = {
# Standard OAuth 2.0 / OIDC claims
"iss": self._issuer, # Issuer — required
"aud": self._audience, # Audience — required
"sub": str(account_id), # Subject (principal)
"iat": now, # Issued at
"exp": now + timedelta(minutes=self._access_expire_minutes), # Expiry
# Domain-specific claims
"account_type": account_type,
"roles": roles,
}
return jwt.encode(payload, self._secret, algorithm=self._algorithm)
def validate_token(self, token: str) -> bool:
try:
jwt.decode(
token,
self._secret,
algorithms=[self._algorithm],
audience=self._audience, # Validates aud claim
issuer=self._issuer, # Validates iss claim
)
return True
except jwt.PyJWTError:
return False
def extract_claims(self, token: str) -> dict:
return jwt.decode(
token,
self._secret,
algorithms=[self._algorithm],
audience=self._audience,
issuer=self._issuer,
)Actor Context (application/security/authorization.py)
The actor context is the auth-safe representation of the authenticated user, available to application handlers:
from dataclasses import dataclass
@dataclass(frozen=True)
class ActorContext:
actor_id: str | None
actor_type: str # "user" | "admin" | "system" | "anonymous"
roles: tuple[str, ...]
account_ids: tuple[str, ...] # IDs this actor can access
@property
def is_authenticated(self) -> bool:
return self.actor_id is not None
@property
def is_admin(self) -> bool:
return "admin" in self.roles
def can_access_account(self, account_id: str) -> bool:
return account_id in self.account_ids or self.is_adminThe actor context is populated by JWTAuthMiddleware into request.state.ctx["actor"]. See 07-request-and-actor-context.md for the full propagation flow.
Route-Level Authorisation (api/security.py)
from fastapi import Depends, Request, HTTPException
from application.security.authorization import ActorContext
def get_actor_context(request: Request) -> ActorContext:
"""Extracts actor from request context. Use in routes that need the current user."""
actor_data = getattr(request.state, "ctx", {}).get("actor", {})
return ActorContext(
actor_id=actor_data.get("actor_id"),
actor_type=actor_data.get("actor_type", "anonymous"),
roles=tuple(actor_data.get("roles", [])),
account_ids=tuple(actor_data.get("account_ids", [])),
)
def require_authenticated(actor: ActorContext = Depends(get_actor_context)) -> ActorContext:
if not actor.is_authenticated:
raise HTTPException(status_code=401, detail="Authentication required")
return actor
def require_admin(actor: ActorContext = Depends(get_actor_context)) -> ActorContext:
if not actor.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
return actor
def require_account_access(account_id: str):
"""Factory for route-specific account access check."""
def dependency(actor: ActorContext = Depends(require_authenticated)) -> ActorContext:
if not actor.can_access_account(account_id):
raise HTTPException(status_code=403, detail="Access denied")
return actor
return dependencyUsage in routes:
@router.get("/{account_id}")
async def get_account(
account_id: UUID,
actor: ActorContext = Depends(require_authenticated),
handler=Depends(get_account_handler),
):
if not actor.can_access_account(str(account_id)):
raise HTTPException(status_code=403)
return await handler.execute(str(account_id))
@router.get("/admin/all")
async def list_all_accounts(
actor: ActorContext = Depends(require_admin),
handler=Depends(get_list_accounts_handler),
):
return await handler.execute()Password Hashing (infrastructure/authentication/password_bcrypt.py)
import bcrypt
from application.authentication.password_service import PasswordService
class BcryptPasswordService(PasswordService):
def hash(self, plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
def verify(self, plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())Auth Factories (infrastructure/authentication/factories.py)
from config.settings import Settings
from infrastructure.authentication.jwt_token import PyJWTService
from infrastructure.authentication.password_bcrypt import BcryptPasswordService
def build_jwt_service(settings: Settings) -> PyJWTService:
return PyJWTService(
secret=settings.auth_jwt_secret,
algorithm=settings.auth_jwt_algorithm,
access_expire_minutes=settings.auth_jwt_access_token_expire_minutes,
issuer=settings.auth_jwt_issuer, # e.g. "https://api.your-service.com"
audience=settings.auth_jwt_audience, # e.g. "your-service-api"
)
def build_password_service(settings: Settings) -> BcryptPasswordService:
return BcryptPasswordService()Rules
- JWT validation happens only in middleware, never in route handlers or application handlers.
- Route handlers receive an
ActorContext— they do not decode tokens or read auth headers. - Application handlers apply business-rule-based authorisation (e.g. "can this actor modify this account?") using
ActorContext. They do not raise HTTP exceptions — they raise domain exceptions (NotAuthorizedError), which exception handlers map to 403 responses. - Public routes (health, docs, login, signup) are listed in
JWTAuthMiddleware.PUBLIC_PATHS— middleware skips validation for these.