Skip to main content

ADR-005: OIDC Authentication Architecture

Status

Accepted

Context

Tasteful applications need secure authentication that supports enterprise identity providers while integrating seamlessly with the async FastAPI architecture and flavor system.

Decision

We selected OpenID Connect (OIDC) with FastAPI Security dependencies for authentication.

Architecture

from tasteful.authn.oidc import OIDCAuthenticationBackend

# OIDC Backend with automatic discovery
oidc_backend = OIDCAuthenticationBackend(
    name="app_auth",
    metadata_url="https://auth.company.com/.well-known/openid_configuration",
    client_id="your-client-id",
    client_secret="your-client-secret",
    scopes="openid profile email roles"
)

# Integration with TastefulApp
app = TastefulApp(
    title="Secure Application",
    authentication_backends=[oidc_backend],  # FastAPI dependencies
    flavors=[UserFlavor]
)

Rationale

Why OIDC?

  • Industry Standard: Built on OAuth 2.0, widely supported
  • Enterprise Ready: Works with Azure AD, Zitadel, Keycloak, Auth0
  • Rich Claims: Access to user roles, permissions, and attributes
  • Automatic Discovery: Metadata endpoints for easy configuration

Why FastAPI Security Dependencies?

  • Native Integration: Leverages FastAPI’s dependency injection
  • Automatic Protection: All endpoints protected without boilerplate
  • Async Performance: Non-blocking token validation
  • Easy Testing: Simple to mock for unit tests

Authentication Flow

  1. Client sends Authorization: Bearer <token> header
  2. Backend extracts and validates token via OIDC introspection
  3. User object created from token claims
  4. User available in request.state.user for controllers

Implementation

Controller Integration

class UserController(BaseController):
    @Get("/profile")
    async def get_profile(self, request: Request):
        user: OIDCUser = request.state.user  # Automatically available
        return {"name": user.name, "email": user.user_info.get("email")}
    
    @Get("/admin")
    async def admin_only(self, request: Request):
        user: OIDCUser = request.state.user
        
        # Manual authorization (formal system planned)
        roles = user.user_info.get("roles", [])
        if "admin" not in roles:
            raise HTTPException(status_code=403, detail="Admin access required")
        
        return {"message": "Admin access granted"}

Custom Authentication Backend

from tasteful.authn.base import AsyncAuthenticationBackend

class APIKeyBackend(AsyncAuthenticationBackend):
    async def authenticate(self, request: Request) -> BaseUser | None:
        api_key = request.headers.get("X-API-Key")
        if api_key and await self.validate_api_key(api_key):
            return BaseUser(name="api_user")
        return None

Consequences

Positive

  • Standards Compliance: OIDC industry standard
  • Enterprise Integration: Works with existing identity providers
  • Developer Experience: Minimal boilerplate, automatic user injection
  • Performance: Async token validation
  • Security: Real-time token validation and revocation

Negative

  • External Dependency: Requires OIDC provider availability
  • Network Latency: Token introspection adds round-trip
  • Configuration Complexity: OIDC setup requires OAuth knowledge

Testing

class MockAuthBackend:
    def __init__(self, mock_user: BaseUser):
        self.mock_user = mock_user
    
    async def __call__(self, request):
        request.state.user = self.mock_user

@pytest.fixture
def authenticated_client():
    mock_auth = MockAuthBackend(BaseUser(name="test_user"))
    app = create_app(authentication_backends=[mock_auth])
    return TestClient(app.app)

References

I