""" OIDC Authentication Module Provides OAuth2/OIDC token validation for FastAPI using Authentik as IdP. Implements bearer token authentication with JWT verification. """ from fastapi import Depends, HTTPException, Security, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from jose import jwt, JWTError import httpx from functools import lru_cache from typing import Dict, Optional from src.logging_config import get_logger logger = get_logger(__name__) security = HTTPBearer(auto_error=False) class OIDCConfig: """OIDC configuration from environment""" def __init__(self): # These will be set from environment variables in config.py self.enabled = False self.issuer = "" self.audiences: list[str] = [] self.jwks_uri = "" def configure(self, enabled: bool, issuer: str, audiences: list[str]): """Configure OIDC settings""" self.enabled = enabled self.issuer = issuer self.audiences = audiences self.jwks_uri = f"{issuer.rstrip('/')}/jwks/" logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}, audiences={audiences}") # Global OIDC config instance oidc_config = OIDCConfig() @lru_cache(maxsize=1) def get_jwks() -> Dict: """ Fetch JSON Web Key Set (JWKS) from Authentik Cached to avoid repeated requests. Cache is cleared on server restart. Returns: JWKS dictionary containing public keys for token verification Raises: HTTPException: If JWKS fetch fails """ if not oidc_config.enabled: return {} try: logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}") response = httpx.get(oidc_config.jwks_uri, timeout=10.0) response.raise_for_status() jwks = response.json() logger.info(f"JWKS fetched successfully ({len(jwks.get('keys', []))} keys)") return jwks except Exception as e: logger.error(f"Failed to fetch JWKS: {e}") raise HTTPException( status_code=503, detail="Authentication service unavailable" ) async def get_current_user( credentials: Optional[HTTPAuthorizationCredentials] = Security(security) ) -> Optional[Dict]: """ Validate OIDC token from Authorization: Bearer header Extracts and validates JWT token from request header. Verifies: - Token signature using JWKS - Token expiration - Issuer matches Authentik - Audience matches core-api Args: credentials: HTTP Bearer token from Authorization header Returns: User claims dictionary containing email, name, groups, etc. Returns None if OIDC is disabled (allows unauthenticated access) Raises: HTTPException 401: If token is invalid, expired, or missing when OIDC enabled """ # If OIDC is disabled, allow all requests (no authentication) if not oidc_config.enabled: logger.debug("OIDC disabled - allowing unauthenticated access") return None # OIDC enabled - token required if not credentials: logger.warning("Authentication required but no token provided") raise HTTPException( status_code=401, detail="Authentication required", headers={"WWW-Authenticate": "Bearer"}, ) token = credentials.credentials try: # Decode token header to get key ID unverified_header = jwt.get_unverified_header(token) kid = unverified_header.get("kid") if not kid: raise HTTPException(status_code=401, detail="Invalid token format") # Find matching key in JWKS jwks = get_jwks() rsa_key = None for key in jwks.get("keys", []): if key.get("kid") == kid: rsa_key = key break if not rsa_key: logger.warning(f"No matching key found for kid: {kid}") raise HTTPException(status_code=401, detail="Invalid token key") # Verify and decode token (accepts any of the configured audiences) payload = jwt.decode( token, rsa_key, algorithms=["RS256"], audience=oidc_config.audiences, issuer=oidc_config.issuer, ) user_email = payload.get("email", "unknown") logger.info(f"Authenticated user: {user_email}") return payload except jwt.ExpiredSignatureError: logger.warning("Token expired") raise HTTPException( status_code=401, detail="Token expired", headers={"WWW-Authenticate": "Bearer"}, ) except jwt.JWTClaimsError as e: logger.warning(f"Invalid token claims: {e}") raise HTTPException( status_code=401, detail="Invalid token claims", headers={"WWW-Authenticate": "Bearer"}, ) except JWTError as e: logger.error(f"JWT validation error: {e}") raise HTTPException( status_code=401, detail="Invalid authentication token", headers={"WWW-Authenticate": "Bearer"}, ) except Exception as e: logger.error(f"Unexpected authentication error: {e}") raise HTTPException( status_code=500, detail="Authentication error", ) async def get_admin_user( user: Optional[Dict] = Depends(get_current_user) ) -> Dict: """ Require admin group membership Use this dependency for endpoints that require admin access. Checks if user is member of 'admin' group in Authentik. Args: user: User claims from get_current_user Returns: User claims dictionary if user is admin Raises: HTTPException 403: If user is not in admin group HTTPException 401: If OIDC enabled but user not authenticated """ # If OIDC disabled, allow all (backward compatibility) if not oidc_config.enabled or user is None: logger.debug("OIDC disabled - allowing admin access") return {"email": "unauthenticated", "groups": ["admin"]} # Check admin group membership groups = user.get("groups", []) if "admin" not in groups and "authentik Admins" not in groups: user_email = user.get("email", "unknown") logger.warning(f"User {user_email} attempted admin access (groups: {groups})") raise HTTPException( status_code=403, detail="Admin access required" ) return user async def get_optional_user( credentials: Optional[HTTPAuthorizationCredentials] = Security(security) ) -> Optional[Dict]: """ Optional authentication - allows both authenticated and unauthenticated access Use for endpoints that should be accessible to everyone but can provide enhanced functionality for authenticated users. Args: credentials: HTTP Bearer token from Authorization header Returns: User claims if valid token provided, None otherwise """ if not credentials or not oidc_config.enabled: return None try: return await get_current_user(credentials) except HTTPException: # Invalid token - return None instead of raising return None async def get_forward_auth_user( request: Request ) -> Optional[Dict]: """ Authentik Forward Auth authentication for external access via NPM This dependency allows: - External access through api.schweitz.net (with Authentik forward auth headers) - REQUIRES authentication - Internal direct access (no forward auth headers) - ALLOWED without authentication When accessing through NPM with Authentik forward auth enabled, NPM adds headers like: - X-authentik-username - X-authentik-email - X-authentik-groups - X-authentik-name - X-authentik-uid Args: request: FastAPI request object containing headers Returns: User info dict if authenticated via forward auth headers None if accessed internally (no forward auth headers) Raises: HTTPException 401: If forward auth headers present but invalid/incomplete """ # Check for Authentik forward auth headers username = request.headers.get("x-authentik-username") email = request.headers.get("x-authentik-email") groups = request.headers.get("x-authentik-groups") name = request.headers.get("x-authentik-name") uid = request.headers.get("x-authentik-uid") # If NO forward auth headers present, this is internal access - allow it if not username and not email: logger.debug("No forward auth headers - allowing internal access") return None # Forward auth headers present (external access via api.schweitz.net) # Validate authentication if not username or not email: logger.warning("Incomplete forward auth headers detected") raise HTTPException( status_code=401, detail="Authentication required - incomplete forward auth headers" ) # Parse groups (comma-separated string to list) groups_list = [g.strip() for g in groups.split(",")] if groups else [] user_info = { "username": username, "email": email, "name": name or username, "groups": groups_list, "uid": uid, "auth_method": "forward_auth" } logger.info(f"Authenticated via forward auth: {email} (groups: {groups_list})") return user_info async def get_forward_auth_admin( user: Optional[Dict] = Depends(get_forward_auth_user) ) -> Dict: """ Require admin access for external requests, allow all internal requests Use this dependency for endpoints that require admin access when accessed externally through api.schweitz.net, but allow unrestricted internal access. Args: user: User info from get_forward_auth_user Returns: User info dict if user is admin or if accessed internally Raises: HTTPException 403: If external user is not in admin/authentik Admins group """ # Internal access (no forward auth headers) - allow all if user is None: logger.debug("Internal access - allowing without admin check") return {"email": "internal", "groups": ["admin"], "auth_method": "internal"} # External access - check admin group membership groups = user.get("groups", []) if "admin" not in groups and "authentik Admins" not in groups: user_email = user.get("email", "unknown") logger.warning(f"User {user_email} attempted admin access (groups: {groups})") raise HTTPException( status_code=403, detail="Admin access required" ) return user