Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook) and split all 44 files exceeding 500 LOC into domain-focused modules: - consent-service (Go): models, handlers, services, database splits - backend-core (Python): security_api, rbac_api, pdf_service, auth splits - admin-core (TypeScript): 5 page.tsx + sidebar extractions - pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits - voice-service (Python): enhanced_task_orchestrator split Result: 0 violations, 36 exempted (pipeline, tests, pure-data files). Go build verified clean. No behavior changes — pure structural splits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
165 lines
5.0 KiB
Python
165 lines
5.0 KiB
Python
"""
|
|
FastAPI Authentication Dependencies and Factory Functions.
|
|
|
|
Provides:
|
|
- get_keycloak_config_from_env(): Create config from env vars
|
|
- get_authenticator(): Create HybridAuthenticator instance
|
|
- get_auth(): Global authenticator singleton
|
|
- get_current_user(): FastAPI dependency for authentication
|
|
- require_role(): FastAPI dependency factory for role-based access
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
from typing import Optional, Dict, Any
|
|
|
|
from fastapi import Request, HTTPException, Depends
|
|
|
|
from .keycloak_auth import (
|
|
KeycloakConfig,
|
|
KeycloakConfigError,
|
|
HybridAuthenticator,
|
|
TokenExpiredError,
|
|
TokenInvalidError,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# =============================================
|
|
# FACTORY FUNCTIONS
|
|
# =============================================
|
|
|
|
def get_keycloak_config_from_env() -> Optional[KeycloakConfig]:
|
|
"""
|
|
Create KeycloakConfig from environment variables.
|
|
|
|
Required env vars:
|
|
- KEYCLOAK_SERVER_URL: e.g., https://keycloak.breakpilot.app
|
|
- KEYCLOAK_REALM: e.g., breakpilot
|
|
- KEYCLOAK_CLIENT_ID: e.g., breakpilot-backend
|
|
|
|
Optional:
|
|
- KEYCLOAK_CLIENT_SECRET: For confidential clients
|
|
- KEYCLOAK_VERIFY_SSL: Default true
|
|
"""
|
|
server_url = os.environ.get("KEYCLOAK_SERVER_URL")
|
|
realm = os.environ.get("KEYCLOAK_REALM")
|
|
client_id = os.environ.get("KEYCLOAK_CLIENT_ID")
|
|
|
|
if not all([server_url, realm, client_id]):
|
|
logger.info("Keycloak not configured, using local JWT only")
|
|
return None
|
|
|
|
return KeycloakConfig(
|
|
server_url=server_url,
|
|
realm=realm,
|
|
client_id=client_id,
|
|
client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET"),
|
|
verify_ssl=os.environ.get("KEYCLOAK_VERIFY_SSL", "true").lower() == "true"
|
|
)
|
|
|
|
|
|
def get_authenticator() -> HybridAuthenticator:
|
|
"""
|
|
Get configured authenticator instance.
|
|
|
|
Uses environment variables to determine configuration.
|
|
"""
|
|
keycloak_config = get_keycloak_config_from_env()
|
|
|
|
# JWT_SECRET is required - no default fallback in production
|
|
jwt_secret = os.environ.get("JWT_SECRET")
|
|
environment = os.environ.get("ENVIRONMENT", "development")
|
|
|
|
if not jwt_secret and environment == "production":
|
|
raise KeycloakConfigError(
|
|
"JWT_SECRET environment variable is required in production"
|
|
)
|
|
|
|
return HybridAuthenticator(
|
|
keycloak_config=keycloak_config,
|
|
local_jwt_secret=jwt_secret,
|
|
environment=environment
|
|
)
|
|
|
|
|
|
# =============================================
|
|
# FASTAPI DEPENDENCY
|
|
# =============================================
|
|
|
|
# Global authenticator instance (lazy-initialized)
|
|
_authenticator: Optional[HybridAuthenticator] = None
|
|
|
|
|
|
def get_auth() -> HybridAuthenticator:
|
|
"""Get or create global authenticator."""
|
|
global _authenticator
|
|
if _authenticator is None:
|
|
_authenticator = get_authenticator()
|
|
return _authenticator
|
|
|
|
|
|
async def get_current_user(request: Request) -> Dict[str, Any]:
|
|
"""
|
|
FastAPI dependency to get current authenticated user.
|
|
|
|
Usage:
|
|
@app.get("/api/protected")
|
|
async def protected_endpoint(user: dict = Depends(get_current_user)):
|
|
return {"user_id": user["user_id"]}
|
|
"""
|
|
auth_header = request.headers.get("authorization", "")
|
|
|
|
if not auth_header.startswith("Bearer "):
|
|
# Check for development mode
|
|
environment = os.environ.get("ENVIRONMENT", "development")
|
|
if environment == "development":
|
|
# Return demo user in development without token
|
|
return {
|
|
"user_id": "10000000-0000-0000-0000-000000000024",
|
|
"email": "demo@breakpilot.app",
|
|
"role": "admin",
|
|
"realm_roles": ["admin"],
|
|
"tenant_id": "a0000000-0000-0000-0000-000000000001",
|
|
"auth_method": "development_bypass"
|
|
}
|
|
raise HTTPException(status_code=401, detail="Missing authorization header")
|
|
|
|
token = auth_header.split(" ")[1]
|
|
|
|
try:
|
|
auth = get_auth()
|
|
return await auth.validate_token(token)
|
|
except TokenExpiredError:
|
|
raise HTTPException(status_code=401, detail="Token expired")
|
|
except TokenInvalidError as e:
|
|
raise HTTPException(status_code=401, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Authentication failed: {e}")
|
|
raise HTTPException(status_code=401, detail="Authentication failed")
|
|
|
|
|
|
async def require_role(required_role: str):
|
|
"""
|
|
FastAPI dependency factory for role-based access.
|
|
|
|
Usage:
|
|
@app.get("/api/admin-only")
|
|
async def admin_endpoint(user: dict = Depends(require_role("admin"))):
|
|
return {"message": "Admin access granted"}
|
|
"""
|
|
async def role_checker(user: dict = Depends(get_current_user)) -> dict:
|
|
user_role = user.get("role", "user")
|
|
realm_roles = user.get("realm_roles", [])
|
|
|
|
if user_role == required_role or required_role in realm_roles:
|
|
return user
|
|
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"Role '{required_role}' required"
|
|
)
|
|
|
|
return role_checker
|