Skip to main content

Authentication Guide

This guide covers implementing authentication in Tasteful applications using the OIDC (OpenID Connect) authentication system. You’ll learn how to configure OIDC backends, create custom user models, and integrate authentication with your flavors.

Quick Start

Get authentication working in your Tasteful application in just a few steps:
from tasteful import TastefulApp
from tasteful.authn.oidc import OIDCAuthenticationBackend

# 1. Configure OIDC backend
auth_backend = OIDCAuthenticationBackend(
    name="my_app",
    metadata_url="https://your-provider.com/.well-known/openid_configuration",
    client_id="your-client-id",
    client_secret="your-client-secret",
    scopes="openid profile email"
)

# 2. Create authenticated application
app = TastefulApp(
    title="Secure App",
    authentication_backends=Security(auth_backend),
    flavors=[YourFlavor]
)

# 3. Access authenticated users in your flavors
class YourController(BaseController):
    @Get("/profile")
    async def get_profile(self, request: Request):
        user = request.state.user  # Automatically available
        return {"name": user.name}

OIDC Authentication Backend

Basic Configuration

The OIDCAuthenticationBackend handles OpenID Connect authentication with automatic token validation:
from tasteful.authn.oidc import OIDCAuthenticationBackend

# Minimal configuration
oidc_backend = OIDCAuthenticationBackend(
    name="company_sso",
    metadata_url="https://auth.company.com/.well-known/openid_configuration",
    client_id="tasteful-client",
    client_secret="your-secret",
    scopes="openid profile email"
)

Advanced Configuration

For more complex scenarios, you can customize the OIDC backend:
# Advanced OIDC configuration
oidc_backend = OIDCAuthenticationBackend(
    name="advanced_oidc",
    metadata_url="https://auth.company.com/.well-known/openid_configuration",
    client_id="advanced-client",
    client_secret="your-secret",
    scopes="openid profile email groups roles",
    introspection_endpoint="https://auth.company.com/oauth/introspect"  # Optional custom endpoint
)

Environment-Based Configuration

Use environment variables for secure configuration:
import os
from tasteful.authn.oidc import OIDCAuthenticationBackend

oidc_backend = OIDCAuthenticationBackend(
    name=os.getenv("OIDC_CLIENT_NAME"),
    metadata_url=os.getenv("OIDC_METADATA_URL"),
    client_id=os.getenv("OIDC_CLIENT_ID"),
    client_secret=os.getenv("OIDC_CLIENT_SECRET"),
    scopes=os.getenv("OIDC_SCOPES", "openid profile email")
)
Create a .env file:
OIDC_CLIENT_NAME=my_tasteful_app
OIDC_METADATA_URL=https://auth.company.com/.well-known/openid_configuration
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_SCOPES=openid profile email groups

User Models

BaseUser

All user models in Tasteful extend the BaseUser class:
from tasteful.authn.base import BaseUser

class BaseUser(BaseModel):
    name: str

OIDCUser

The OIDCUser model includes OIDC token claims:
from tasteful.authn.oidc import OIDCUser

# OIDCUser automatically created by OIDCAuthenticationBackend
class OIDCUser(BaseUser):
    user_info: dict  # Contains all OIDC token claims
Access OIDC claims in your controllers:
from fastapi import Request
from tasteful.authn.oidc import OIDCUser

class UserController(BaseController):
    @Get("/user-details")
    async def get_user_details(self, request: Request):
        user: OIDCUser = request.state.user
        
        return {
            "name": user.name,
            "email": user.user_info.get("email"),
            "groups": user.user_info.get("groups", []),
            "roles": user.user_info.get("roles", []),
            "all_claims": user.user_info
        }

Custom User Models

Create custom user models for application-specific needs:
from tasteful.authn.base import BaseUser
from typing import Optional, List
from pydantic import Field

class CustomUser(BaseUser):
    email: str
    roles: List[str] = Field(default_factory=list)
    department: Optional[str] = None
    is_active: bool = True
    permissions: List[str] = Field(default_factory=list)
    
    @property
    def is_admin(self) -> bool:
        return "admin" in self.roles
    
    def has_permission(self, permission: str) -> bool:
        return permission in self.permissions

# Use custom user model with custom authentication backend
class CustomOIDCBackend(OIDCAuthenticationBackend):
    async def _validate_token(self, token: str) -> CustomUser | None:
        # Get standard OIDC validation
        result = await super()._validate_token(token)
        
        if result and result.user_info.get("active", False):
            # Create custom user from OIDC claims
            return CustomUser(
                name=result.user_info.get("name"),
                email=result.user_info.get("email"),
                roles=result.user_info.get("roles", []),
                department=result.user_info.get("department"),
                permissions=result.user_info.get("permissions", [])
            )
        return None

Async Authentication Backend

For high-performance applications, use the async authentication pattern:
from tasteful.authn.base import AsyncAuthenticationBackend, BaseUser
from fastapi import Request
import httpx

class CustomAsyncBackend(AsyncAuthenticationBackend):
    def __init__(self, api_url: str, api_key: str):
        super().__init__()
        self.api_url = api_url
        self.api_key = api_key
    
    async def authenticate(self, request: Request) -> BaseUser | None:
        # Extract token from request
        authorization = request.headers.get("Authorization")
        if not authorization or not authorization.startswith("Bearer "):
            return None
        
        token = authorization.split(" ")[1]
        
        # Async token validation
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.api_url}/validate",
                headers={"Authorization": f"Bearer {self.api_key}"},
                json={"token": token}
            )
            
            if response.status_code == 200:
                user_data = response.json()
                return BaseUser(name=user_data["username"])
        
        return None

# Use custom async backend
custom_backend = CustomAsyncBackend(
    api_url="https://auth-service.company.com",
    api_key="service-api-key"
)

app = TastefulApp(
    title="Async Auth App",
    authentication_backends=[custom_backend],
    flavors=[YourFlavor]
)

Integration with TastefulApp

Single Authentication Backend

Most applications use a single authentication method:
from tasteful import TastefulApp
from tasteful.authn.oidc import OIDCAuthenticationBackend

# Configure authentication
auth_backend = OIDCAuthenticationBackend(
    name="main_auth",
    metadata_url="https://auth.company.com/.well-known/openid_configuration",
    client_id="tasteful-app",
    client_secret="your-secret",
    scopes="openid profile email"
)

# Create application
app = TastefulApp(
    title="My Secure Application",
    version="1.0.0",
    authentication_backends=[auth_backend],
    flavors=[
        UserFlavor,
        AdminFlavor,
        APIFlavor
    ]
)

Multiple Authentication Backends

For applications with different authentication needs:
from tasteful.authn.oidc import OIDCAuthenticationBackend
from tasteful.authn.base import AuthenticationBackend

# User authentication via OIDC
user_auth = OIDCAuthenticationBackend(
    name="user_sso",
    metadata_url="https://auth.company.com/.well-known/openid_configuration",
    client_id="user-client",
    client_secret="user-secret",
    scopes="openid profile email"
)

# Service authentication via API keys
class ServiceAuthBackend(AuthenticationBackend):
    def authenticate(self, request):
        api_key = request.headers.get("X-API-Key")
        if api_key and self.validate_service_key(api_key):
            return BaseUser(name="service_account")
        return None
    
    def validate_service_key(self, key: str) -> bool:
        # Validate service API key
        return key in ["service-key-1", "service-key-2"]

service_auth = ServiceAuthBackend()

# Application with multiple auth methods
app = TastefulApp(
    title="Multi-Auth Application",
    authentication_backends=[user_auth, service_auth],
    flavors=[UserFlavor, ServiceFlavor]
)

Using Authentication in Flavors

Basic User Access

Access authenticated users in your flavor controllers:
from fastapi import Request
from tasteful.base_flavor import BaseController, BaseService
from tasteful.authn.base import BaseUser

class UserService(BaseService):
    def get_user_profile(self, user: BaseUser) -> dict:
        return {
            "name": user.name,
            "authenticated": True
        }

class UserController(BaseController):
    def __init__(self, service: UserService):
        super().__init__(prefix="/users", tags=["users"])
        self.service = service
    
    @Get("/profile")
    async def get_profile(self, request: Request):
        # User automatically available from authentication backend
        user: BaseUser = request.state.user
        return self.service.get_user_profile(user)
    
    @Get("/me")
    async def get_current_user(self, request: Request):
        user: BaseUser = request.state.user
        return {"user_id": user.name}

OIDC Claims Access

When using OIDC authentication, access token claims:
from fastapi import Request, HTTPException
from tasteful.authn.oidc import OIDCUser

class AdminController(BaseController):
    def __init__(self):
        super().__init__(prefix="/admin", tags=["admin"])
    
    @Get("/dashboard")
    async def admin_dashboard(self, request: Request):
        user: OIDCUser = request.state.user
        
        # Check admin role from OIDC claims
        roles = user.user_info.get("roles", [])
        if "admin" not in roles:
            raise HTTPException(status_code=403, detail="Admin access required")
        
        return {
            "message": "Welcome to admin dashboard",
            "user": user.name,
            "roles": roles
        }
    
    @Get("/users")
    async def list_users(self, request: Request):
        user: OIDCUser = request.state.user
        
        # Check specific permissions
        permissions = user.user_info.get("permissions", [])
        if "users:read" not in permissions:
            raise HTTPException(status_code=403, detail="Insufficient permissions")
        
        return {"users": ["user1", "user2", "user3"]}

Optional Authentication

For endpoints that work with or without authentication:
from fastapi import Request
from typing import Optional

class PublicController(BaseController):
    def __init__(self):
        super().__init__(prefix="/public", tags=["public"])
    
    @Get("/content")
    async def get_content(self, request: Request):
        # Check if user is authenticated (optional)
        user: Optional[BaseUser] = getattr(request.state, 'user', None)
        
        if user:
            return {
                "message": f"Hello {user.name}!",
                "content": "Premium content for authenticated users"
            }
        else:
            return {
                "message": "Hello anonymous user!",
                "content": "Basic content for everyone"
            }

Error Handling

Authentication backends automatically handle common error scenarios:

Automatic Error Responses

  • 401 Unauthorized: Missing or invalid token
  • 500 Internal Server Error: Backend authentication errors
# These responses are automatic - no additional code needed

# Missing Authorization header
{
    "detail": "Unauthorized"
}

# Invalid token
{
    "detail": "Unauthorized"
}

# Backend error (network issues, invalid configuration, etc.)
{
    "detail": "Internal Server Error"
}

Custom Error Handling

Add custom error handling in your authentication backend:
from fastapi import HTTPException
from tasteful.authn.base import AsyncAuthenticationBackend

class CustomErrorBackend(AsyncAuthenticationBackend):
    async def authenticate(self, request: Request) -> BaseUser | None:
        try:
            # Your authentication logic
            return await self.validate_user(request)
        except TokenExpiredError:
            raise HTTPException(
                status_code=401, 
                detail="Token has expired"
            )
        except InvalidTokenError:
            raise HTTPException(
                status_code=401, 
                detail="Invalid token format"
            )
        except ServiceUnavailableError:
            raise HTTPException(
                status_code=503, 
                detail="Authentication service unavailable"
            )

Testing Authentication

Testing with Mock Users

Create mock authentication for testing:
import pytest
from fastapi.testclient import TestClient
from tasteful.authn.base import BaseUser

class MockAuthBackend:
    def __init__(self, mock_user: BaseUser = None):
        self.mock_user = mock_user or BaseUser(name="test_user")
    
    async def __call__(self, request):
        request.state.user = self.mock_user

@pytest.fixture
def authenticated_client():
    from your_app import create_app
    
    # Create app with mock authentication
    mock_auth = MockAuthBackend(BaseUser(name="test_user"))
    app = create_app(authentication_backends=[mock_auth])
    
    return TestClient(app.app)

def test_authenticated_endpoint(authenticated_client):
    response = authenticated_client.get("/users/profile")
    assert response.status_code == 200
    assert response.json()["user_id"] == "test_user"

Integration Testing

Test with real OIDC tokens in integration tests:
import pytest
from fastapi.testclient import TestClient

@pytest.fixture
def oidc_token():
    # Get real OIDC token for testing
    # This would typically come from your test OIDC provider
    return "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."

def test_oidc_authentication(client, oidc_token):
    headers = {"Authorization": f"Bearer {oidc_token}"}
    response = client.get("/users/profile", headers=headers)
    assert response.status_code == 200

Common Patterns

Authorization System Status: While authentication is fully implemented, formal authorization decorators and middleware are planned for future releases. The examples below show manual authorization patterns using OIDC token claims.

Manual Role-Based Access Control

Implement role checking using OIDC claims:
from functools import wraps
from fastapi import HTTPException, Request

# Current approach: Manual role checking in each endpoint
class SecureController(BaseController):
    @Get("/admin-only")
    async def admin_endpoint(self, request: Request):
        user: OIDCUser = request.state.user
        
        # Manual role check using OIDC claims
        roles = user.user_info.get("roles", [])
        if "admin" not in roles:
            raise HTTPException(status_code=403, detail="Admin role required")
        
        return {"message": "Admin access granted"}

# Future approach with decorators (planned, not yet implemented):
# from tasteful.authn.decorators import require_role
# 
# class SecureController(BaseController):
#     @Get("/admin-only")
#     @require_role("admin")  # Future feature
#     async def admin_endpoint(self, request: Request):
#         return {"message": "Admin access granted"}

Manual Permission-Based Access

Implement fine-grained permission checking:
# Current approach: Manual permission checking in each endpoint
class ResourceController(BaseController):
    @Get("/sensitive-data")
    async def get_sensitive_data(self, request: Request):
        user: OIDCUser = request.state.user
        
        # Manual permission check using OIDC claims
        permissions = user.user_info.get("permissions", [])
        if "data:read" not in permissions:
            raise HTTPException(status_code=403, detail="Read permission required")
        
        return {"data": "sensitive information"}
    
    @Post("/sensitive-data")
    async def create_sensitive_data(self, request: Request):
        user: OIDCUser = request.state.user
        
        # Manual permission check using OIDC claims
        permissions = user.user_info.get("permissions", [])
        if "data:write" not in permissions:
            raise HTTPException(status_code=403, detail="Write permission required")
        
        return {"message": "Data created"}

# Future approach with decorators (planned, not yet implemented):
# from tasteful.authn.decorators import require_permission
# 
# class ResourceController(BaseController):
#     @Get("/sensitive-data")
#     @require_permission("data:read")  # Future feature
#     async def get_sensitive_data(self, request: Request):
#         return {"data": "sensitive information"}

Troubleshooting

Common Issues

Token Validation Fails
  • Verify OIDC metadata URL is accessible
  • Check client ID and secret configuration
  • Ensure token is properly formatted (Bearer token)
User Object Not Available
  • Confirm authentication backend is configured in TastefulApp
  • Check that endpoints are being called with valid Authorization header
  • Verify OIDC provider is returning expected claims
Performance Issues
  • Use AsyncAuthenticationBackend for high-throughput applications
  • Consider token caching for frequently accessed endpoints
  • Monitor OIDC provider response times

Debug Mode

Enable debug logging to troubleshoot authentication issues:
import logging

# Enable debug logging
logging.basicConfig(level=logging.DEBUG)

# Your OIDC backend will now log detailed information
oidc_backend = OIDCAuthenticationBackend(
    name="debug_app",
    metadata_url="https://auth.company.com/.well-known/openid_configuration",
    client_id="debug-client",
    client_secret="debug-secret",
    scopes="openid profile email"
)

Adding Authentication to Existing Applications

Updating Your TastefulApp

If you have an existing Tasteful application without authentication, adding it is straightforward:
# Before: No authentication
from tasteful import TastefulApp

app = TastefulApp(
    title="My Application",
    flavors=[YourFlavor],
    authentication_backends=[]  # No authentication
)

# After: With OIDC authentication
from tasteful import TastefulApp
from tasteful.authn.oidc import OIDCAuthenticationBackend

# Configure authentication
auth_backend = OIDCAuthenticationBackend(
    name="my_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"
)

app = TastefulApp(
    title="My Secure Application",
    flavors=[YourFlavor],
    authentication_backends=[auth_backend]  # Authentication enabled
)

Updating Your Flavors

Once authentication is enabled, update your flavor controllers to access authenticated users:
# Before: No user access
class MyController(BaseController):
    @Get("/data")
    async def get_data(self):
        return {"data": "public data"}

# After: With authenticated user access
class MyController(BaseController):
    @Get("/data")
    async def get_data(self, request: Request):
        user: BaseUser = request.state.user
        return {
            "data": "personalized data",
            "user": user.name
        }

Gradual Migration

For gradual migration, you can make authentication optional on some endpoints:
from typing import Optional

class MigrationController(BaseController):
    @Get("/mixed-endpoint")
    async def mixed_endpoint(self, request: Request):
        # Check if user is authenticated (optional)
        user: Optional[BaseUser] = getattr(request.state, 'user', None)
        
        if user:
            return {"message": f"Hello {user.name}!", "authenticated": True}
        else:
            return {"message": "Hello anonymous user!", "authenticated": False}

Next Steps

I