Files
breakpilot-lehrer/backend-lehrer/auth/keycloak_auth.py
Benjamin Admin bd4b956e3c [split-required] Split final 43 files (500-668 LOC) to complete refactoring
klausur-service (11 files):
- cv_gutter_repair, ocr_pipeline_regression, upload_api
- ocr_pipeline_sessions, smart_spell, nru_worksheet_generator
- ocr_pipeline_overlays, mail/aggregator, zeugnis_api
- cv_syllable_detect, self_rag

backend-lehrer (17 files):
- classroom_engine/suggestions, generators/quiz_generator
- worksheets_api, llm_gateway/comparison, state_engine_api
- classroom/models (→ 4 submodules), services/file_processor
- alerts_agent/api/wizard+digests+routes, content_generators/pdf
- classroom/routes/sessions, llm_gateway/inference
- classroom_engine/analytics, auth/keycloak_auth
- alerts_agent/processing/rule_engine, ai_processor/print_versions

agent-core (5 files):
- brain/memory_store, brain/knowledge_graph, brain/context_manager
- orchestrator/supervisor, sessions/session_manager

admin-lehrer (5 components):
- GridOverlay, StepGridReview, DevOpsPipelineSidebar
- DataFlowDiagram, sbom/wizard/page

website (2 files):
- DependencyMap, lehrer/abitur-archiv

Other: nibis_ingestion, grid_detection_service, export-doclayout-onnx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 09:41:42 +02:00

263 lines
10 KiB
Python

"""
Keycloak Authentication Module
Implements token validation against Keycloak JWKS endpoint.
This module handles authentication (who is the user?), while
rbac.py handles authorization (what can the user do?).
Architecture:
- Keycloak validates JWT tokens and provides basic identity
- Our custom rbac.py handles fine-grained permissions
"""
import os
import httpx
import jwt
from jwt import PyJWKClient
import logging
from typing import Optional, Dict, Any
from .keycloak_models import (
KeycloakConfig,
KeycloakUser,
KeycloakAuthError,
TokenExpiredError,
TokenInvalidError,
KeycloakConfigError,
get_keycloak_config_from_env,
)
logger = logging.getLogger(__name__)
class KeycloakAuthenticator:
"""Validates JWT tokens against Keycloak."""
def __init__(self, config: KeycloakConfig):
self.config = config
self._jwks_client: Optional[PyJWKClient] = None
self._http_client: Optional[httpx.AsyncClient] = None
@property
def jwks_client(self) -> PyJWKClient:
if self._jwks_client is None:
self._jwks_client = PyJWKClient(self.config.jwks_url, cache_keys=True, lifespan=3600)
return self._jwks_client
async def get_http_client(self) -> httpx.AsyncClient:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(verify=self.config.verify_ssl, timeout=30.0)
return self._http_client
async def close(self):
if self._http_client and not self._http_client.is_closed:
await self._http_client.aclose()
def validate_token_sync(self, token: str) -> KeycloakUser:
"""Synchronously validate a JWT token against Keycloak JWKS."""
try:
signing_key = self.jwks_client.get_signing_key_from_jwt(token)
payload = jwt.decode(
token, signing_key.key, algorithms=["RS256"],
audience=self.config.client_id, issuer=self.config.issuer_url,
options={"verify_exp": True, "verify_iat": True, "verify_aud": True, "verify_iss": True}
)
return self._extract_user(payload)
except jwt.ExpiredSignatureError:
raise TokenExpiredError("Token has expired")
except jwt.InvalidAudienceError:
raise TokenInvalidError("Invalid token audience")
except jwt.InvalidIssuerError:
raise TokenInvalidError("Invalid token issuer")
except jwt.InvalidTokenError as e:
raise TokenInvalidError(f"Invalid token: {e}")
except Exception as e:
logger.error(f"Token validation failed: {e}")
raise TokenInvalidError(f"Token validation failed: {e}")
async def validate_token(self, token: str) -> KeycloakUser:
"""Asynchronously validate a JWT token."""
return self.validate_token_sync(token)
async def get_userinfo(self, token: str) -> Dict[str, Any]:
"""Fetch user info from Keycloak userinfo endpoint."""
client = await self.get_http_client()
try:
response = await client.get(self.config.userinfo_url, headers={"Authorization": f"Bearer {token}"})
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
raise TokenExpiredError("Token is invalid or expired")
raise TokenInvalidError(f"Failed to fetch userinfo: {e}")
def _extract_user(self, payload: Dict[str, Any]) -> KeycloakUser:
"""Extract KeycloakUser from JWT payload."""
realm_access = payload.get("realm_access", {})
realm_roles = realm_access.get("roles", [])
resource_access = payload.get("resource_access", {})
client_roles = {}
for client_id, access in resource_access.items():
client_roles[client_id] = access.get("roles", [])
groups = payload.get("groups", [])
tenant_id = payload.get("tenant_id") or payload.get("school_id")
return KeycloakUser(
user_id=payload.get("sub", ""), email=payload.get("email", ""),
email_verified=payload.get("email_verified", False),
name=payload.get("name"), given_name=payload.get("given_name"),
family_name=payload.get("family_name"),
realm_roles=realm_roles, client_roles=client_roles,
groups=groups, tenant_id=tenant_id, raw_claims=payload
)
class HybridAuthenticator:
"""Hybrid authenticator supporting both Keycloak and local JWT tokens."""
def __init__(self, keycloak_config=None, local_jwt_secret=None, environment="development"):
self.environment = environment
self.keycloak_enabled = keycloak_config is not None
self.local_jwt_secret = local_jwt_secret
self.keycloak_auth = KeycloakAuthenticator(keycloak_config) if keycloak_config else None
async def validate_token(self, token: str) -> Dict[str, Any]:
"""Validate token using appropriate method."""
if not token:
raise TokenInvalidError("No token provided")
try:
unverified = jwt.decode(token, options={"verify_signature": False})
issuer = unverified.get("iss", "")
except jwt.InvalidTokenError:
raise TokenInvalidError("Cannot decode token")
if self.keycloak_auth and self.keycloak_auth.config.issuer_url in issuer:
kc_user = await self.keycloak_auth.validate_token(token)
return self._keycloak_user_to_dict(kc_user)
if self.local_jwt_secret:
return self._validate_local_token(token)
raise TokenInvalidError("No valid authentication method available")
def _validate_local_token(self, token: str) -> Dict[str, Any]:
"""Validate token with local JWT secret."""
if not self.local_jwt_secret:
raise KeycloakConfigError("Local JWT secret not configured")
try:
payload = jwt.decode(token, self.local_jwt_secret, algorithms=["HS256"])
return {
"user_id": payload.get("user_id", payload.get("sub", "")),
"email": payload.get("email", ""),
"name": payload.get("name", ""),
"role": payload.get("role", "user"),
"realm_roles": [payload.get("role", "user")],
"tenant_id": payload.get("tenant_id", payload.get("school_id")),
"auth_method": "local_jwt"
}
except jwt.ExpiredSignatureError:
raise TokenExpiredError("Token has expired")
except jwt.InvalidTokenError as e:
raise TokenInvalidError(f"Invalid local token: {e}")
def _keycloak_user_to_dict(self, user: KeycloakUser) -> Dict[str, Any]:
"""Convert KeycloakUser to dict compatible with existing code."""
role = "user"
if user.is_admin():
role = "admin"
elif user.is_teacher():
role = "teacher"
return {
"user_id": user.user_id, "email": user.email,
"name": user.name or f"{user.given_name or ''} {user.family_name or ''}".strip(),
"role": role, "realm_roles": user.realm_roles,
"client_roles": user.client_roles, "groups": user.groups,
"tenant_id": user.tenant_id, "email_verified": user.email_verified,
"auth_method": "keycloak"
}
async def close(self):
if self.keycloak_auth:
await self.keycloak_auth.close()
# =============================================
# FACTORY FUNCTIONS
# =============================================
def get_authenticator() -> HybridAuthenticator:
"""Get configured authenticator instance."""
keycloak_config = get_keycloak_config_from_env()
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
# =============================================
from fastapi import Request, HTTPException, Depends
_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."""
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
environment = os.environ.get("ENVIRONMENT", "development")
if environment == "development":
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."""
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