#!/usr/bin/env python3 """ API Key Authentication Middleware for ReviewIQ Phase 7. Security Model: - API keys are never stored in plain text - Only SHA-256 hashes are stored in the database - First 8 characters (prefix) are stored for identification in logs/UI - Keys follow format: "riq_" + 32 random alphanumeric characters Authentication Flow: 1. Client sends API key in X-API-Key header 2. Server hashes the received key with SHA-256 3. Server looks up the hash in api_keys table 4. If found, active, and not expired, request is authenticated 5. Scopes are checked for protected endpoints """ import hashlib import secrets import string import logging from datetime import datetime from functools import wraps from typing import Optional, List, Callable from uuid import UUID from fastapi import Request, HTTPException, Depends from fastapi.security import APIKeyHeader log = logging.getLogger(__name__) # Security header for API key api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) # Key format constants API_KEY_PREFIX = "riq_" API_KEY_RANDOM_LENGTH = 32 API_KEY_PREFIX_STORE_LENGTH = 8 # First 8 chars stored for identification def generate_api_key() -> str: """ Generate a secure random API key with prefix. Format: "riq_" + 32 random alphanumeric characters Example: "riq_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" Returns: Secure random API key string """ # Use cryptographically secure random generator alphabet = string.ascii_lowercase + string.digits random_part = ''.join(secrets.choice(alphabet) for _ in range(API_KEY_RANDOM_LENGTH)) return f"{API_KEY_PREFIX}{random_part}" class APIKeyAuth: """ API Key authentication middleware. Usage: from api.middleware.auth import APIKeyAuth # Initialize with database auth = APIKeyAuth(db) # Use as dependency @app.get("/protected") async def protected_endpoint(client: dict = Depends(auth.verify_api_key)): return {"client_id": client["client_id"]} # Require specific scope @app.post("/admin-only") async def admin_endpoint(client: dict = Depends(auth.require_scope("admin"))): return {"message": "Admin access granted"} """ def __init__(self, db): """ Initialize API key authentication. Args: db: DatabaseManager instance with api key methods """ self.db = db async def verify_api_key( self, request: Request, api_key: Optional[str] = Depends(api_key_header) ) -> dict: """ Verify API key and return client info. This is a FastAPI dependency that validates the X-API-Key header and returns information about the authenticated client. Args: request: FastAPI request object api_key: API key from X-API-Key header Returns: dict: Client information: { "client_id": "veritas_123", "key_id": "uuid-of-key", "key_prefix": "riq_a1b2", "name": "Production Key", "scopes": ["jobs:read", "jobs:write"], "rate_limit_rpm": 60 } Raises: HTTPException 401: If API key is missing or invalid HTTPException 403: If API key is inactive or expired """ if not api_key: log.warning(f"Missing API key for request: {request.method} {request.url.path}") raise HTTPException( status_code=401, detail="Missing API key. Include X-API-Key header.", headers={"WWW-Authenticate": "ApiKey"} ) # Validate key format if not api_key.startswith(API_KEY_PREFIX): log.warning(f"Invalid API key format (wrong prefix): {api_key[:8]}...") raise HTTPException( status_code=401, detail="Invalid API key format.", headers={"WWW-Authenticate": "ApiKey"} ) # Hash the key for lookup key_hash = self.hash_api_key(api_key) # Look up the key in database key_data = await self.db.get_api_key_by_hash(key_hash) if not key_data: # Log only the prefix for security log.warning(f"Unknown API key attempted: {api_key[:12]}...") raise HTTPException( status_code=401, detail="Invalid API key.", headers={"WWW-Authenticate": "ApiKey"} ) # Check if key is active if not key_data.get('is_active', False): log.warning(f"Inactive API key used: {key_data['key_prefix']} (client: {key_data['client_id']})") raise HTTPException( status_code=403, detail="API key has been revoked." ) # Check expiration expires_at = key_data.get('expires_at') if expires_at and expires_at < datetime.utcnow(): log.warning(f"Expired API key used: {key_data['key_prefix']} (client: {key_data['client_id']})") raise HTTPException( status_code=403, detail="API key has expired." ) # Update last_used_at timestamp (fire and forget, don't block request) try: await self.db.update_api_key_last_used(key_data['id']) except Exception as e: # Don't fail the request if timestamp update fails log.error(f"Failed to update last_used_at for key {key_data['key_prefix']}: {e}") # Log successful authentication (at debug level to avoid log spam) log.debug(f"Authenticated: client={key_data['client_id']} key={key_data['key_prefix']}") # Return client info return { "client_id": key_data['client_id'], "key_id": str(key_data['id']), "key_prefix": key_data['key_prefix'], "name": key_data['name'], "scopes": key_data.get('scopes', []), "rate_limit_rpm": key_data.get('rate_limit_rpm', 60) } def require_scope(self, scope: str) -> Callable: """ Create a dependency that requires a specific scope. Usage: @app.post("/jobs") async def create_job(client: dict = Depends(auth.require_scope("jobs:write"))): # Only accessible with jobs:write scope pass Args: scope: Required scope string (e.g., "jobs:read", "jobs:write", "admin") Returns: FastAPI dependency function that verifies the API key and checks scope """ async def scope_dependency( request: Request, api_key: Optional[str] = Depends(api_key_header) ) -> dict: # First verify the API key client = await self.verify_api_key(request, api_key) # Check if client has the required scope client_scopes = client.get('scopes', []) # Admin scope grants all permissions if 'admin' in client_scopes: return client if scope not in client_scopes: log.warning( f"Scope denied: client={client['client_id']} " f"required={scope} has={client_scopes}" ) raise HTTPException( status_code=403, detail=f"Insufficient permissions. Required scope: {scope}" ) return client return scope_dependency def require_any_scope(self, scopes: List[str]) -> Callable: """ Create a dependency that requires any one of the specified scopes. Usage: @app.get("/jobs/{job_id}") async def get_job(client: dict = Depends(auth.require_any_scope(["jobs:read", "jobs:write"]))): pass Args: scopes: List of acceptable scopes (client needs at least one) Returns: FastAPI dependency function """ async def scope_dependency( request: Request, api_key: Optional[str] = Depends(api_key_header) ) -> dict: client = await self.verify_api_key(request, api_key) client_scopes = client.get('scopes', []) # Admin scope grants all permissions if 'admin' in client_scopes: return client # Check if client has any of the required scopes if not any(s in client_scopes for s in scopes): log.warning( f"Scope denied: client={client['client_id']} " f"required_any={scopes} has={client_scopes}" ) raise HTTPException( status_code=403, detail=f"Insufficient permissions. Required one of: {', '.join(scopes)}" ) return client return scope_dependency @staticmethod def hash_api_key(api_key: str) -> str: """ Hash API key for storage/lookup using SHA-256. This is a one-way hash - the original key cannot be recovered. We use SHA-256 for consistency and security. Args: api_key: Plain text API key Returns: 64-character hexadecimal hash string """ return hashlib.sha256(api_key.encode('utf-8')).hexdigest() @staticmethod def get_key_prefix(api_key: str) -> str: """ Extract the identifying prefix from an API key. This prefix is safe to store and display as it cannot be used to reconstruct the full key. Args: api_key: Plain text API key Returns: First 8 characters of the key (e.g., "riq_a1b2") """ return api_key[:API_KEY_PREFIX_STORE_LENGTH] # Convenience function for creating auth instance def create_auth(db) -> APIKeyAuth: """ Factory function to create APIKeyAuth instance. Args: db: DatabaseManager instance Returns: Configured APIKeyAuth instance """ return APIKeyAuth(db) # Available scopes documentation AVAILABLE_SCOPES = { "jobs:read": "Read job status and results", "jobs:write": "Create and cancel jobs", "batches:read": "Read batch status and results", "batches:write": "Create and manage batches", "webhooks:manage": "Configure webhook endpoints", "admin": "Full administrative access (includes all other scopes)" }