Skip to content

Strategy pattern for authentication methods

Context and Problem Statement

The system supports multiple authentication methods (OAuth2/OIDC, JWT Bearer, API Key). Each method has different dependencies and validation logic. How should AuthenticationService handle dispatch to the correct authentication flow?

Decision Drivers

  • Interface Segregation Principle: each method authenticator should only depend on what it needs
  • Open/Closed Principle: adding a new auth method should not modify existing code
  • No isinstance checks in business logic (see ADR-0005)
  • Each authentication method has fundamentally different dependencies (token validator vs API key validator)

Considered Options

  • Monolithic service with branching (if method == ...)
  • Chain of Responsibility pattern
  • Strategy pattern with MethodAuthenticator Protocol and Mapping[AuthMethod, MethodAuthenticator]

Decision Outcome

Chosen option: "Strategy pattern with Protocol-based dispatch", because it satisfies both ISP and OCP while keeping the dispatcher trivially simple.

How it works

Each auth method is implemented as a separate class conforming to the MethodAuthenticator Protocol:

class MethodAuthenticator(Protocol):
    def authenticate(self, credential: Credential) -> AuthenticationResult: ...

AuthenticationService receives a Mapping[AuthMethod, MethodAuthenticator] at construction and dispatches:

class AuthenticationService:
    def __init__(self, strategies: Mapping[AuthMethod, MethodAuthenticator]) -> None:
        self._strategies = strategies

    def authenticate(self, credential: Credential) -> AuthenticationResult:
        strategy = self._strategies.get(credential.method)
        if strategy is None:
            return AuthenticationResult(...)  # unsupported method
        return strategy.authenticate(credential)

Current strategy implementations

Strategy Auth method Dependencies
TokenMethodAuthenticator JWT_BEARER, OAUTH2_OIDC TokenValidator, PrincipalResolver
ApiKeyMethodAuthenticator API_KEY ApiKeyValidator

Consequences

  • Good, because each authenticator class has only its required dependencies (ISP)
  • Good, because adding a new auth method means adding a new class and a mapping entry (OCP)
  • Good, because no branching or isinstance in the dispatcher
  • Good, because each strategy is independently testable with focused stubs
  • Neutral, because the mapping must be wired at composition time (adapters layer)
  • Bad, because slightly more types than a single monolithic class

Confirmation

  • Architecture tests enforce that services only import from domain and ports
  • Each strategy is tested independently in test_authentication_service.py
  • The dispatcher is tested for both known and unsupported methods

More Information