Documentation

Authentication Patterns

Principal model, authentication methods as adapters (password, OTP, OAuth), access/refresh tokens, and OAuth 2.0 / OIDC claims.

Show:

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 ActorContext produced 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:

  1. Principal submits identifier (email or phone) + password
  2. Adapter fetches account, verifies password hash (constant-time comparison)
  3. On success: issues access token + refresh token

OTP (One-Time Password) Authentication

Credential stored: Transient OTPChallenge — hashed OTP code, expires in minutes.

Flow:

  1. Principal submits identifier; system generates OTP, hashes it, sends out-of-band (email/SMS)
  2. Principal submits OTP code
  3. Adapter verifies code hash (constant-time), checks expiry and attempt count
  4. 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: OAuthCredentialprovider_user_id (immutable link to provider identity), linked at timestamp.

Flow:

  1. Principal initiates OAuth; system generates authorization URL with state parameter
  2. Principal authenticates at provider (Google, Facebook, etc.) and grants consent
  3. Provider redirects with authorization code; system exchanges code for provider access token
  4. System fetches principal's identity from provider (email, name, provider user ID)
  5. System finds or creates account linked to provider_user_id
  6. 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:

  1. External service sends request with signature header
  2. Adapter computes expected HMAC, compares with constant-time comparison
  3. On success: populates ActorContext with actor_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"] = actor

The 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:

StandardStatusNote
iss claimRequired — implementToken origin must be attributable
aud claimRequired — implementPrevents token replay across services
nonce claimOptionalRelevant only for OIDC browser flows
Identity Token (ID Token)Not implementedAccess token carries identity claims instead
ScopesNot implementedRole-based authorization used instead
PKCENot implementedState-only CSRF; acceptable for SPA/mobile
Token introspection endpointNot implementedStateless 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_admin

The 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 dependency

Usage 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.