""" OIDC Authentication Module Provides OAuth2/OIDC token validation for FastAPI using Authentik as IdP. Implements bearer token authentication with JWT verification. Permission Format: domain.category:action - domain: Main area (control-room, library, media, ai, etc.) - category: Sub-area within domain (general for full domain, or specific tools) - action: Permission level (viewer, user, editor, admin) Examples: - control-room.general:admin - Full access to Control Room - media.general:viewer - View-only access to Media area Action Hierarchy (higher implies lower): - admin > editor > user > viewer """ 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 Callable, Dict, List, Optional from src.shared.logging import get_logger # ============================================================================= # Permission System # ============================================================================= # Action hierarchy: higher actions imply lower ones ACTION_HIERARCHY: Dict[str, int] = { "viewer": 1, "user": 2, "editor": 3, "admin": 4, } # Valid domains (main areas) VALID_DOMAINS = { "control-room", "library", "media", "ai", "housekeeper", "developer", "documents", "gaming", "admin", # Global admin domain } # Default category for general domain access DEFAULT_CATEGORY = "general" 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.issuers: list[str] = [] self.audiences: list[str] = [] def configure(self, enabled: bool, issuers: list[str], audiences: list[str]): """Configure OIDC settings""" self.enabled = enabled self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash self.audiences = audiences logger.info(f"OIDC configured: enabled={enabled}, issuers={self.issuers}, audiences={audiences}") def get_jwks_uri(self, issuer: str) -> str: """Get JWKS URI for a specific issuer""" return f"{issuer.rstrip('/')}/jwks/" def is_valid_issuer(self, issuer: str) -> bool: """Check if issuer is in the allowed list""" normalized = issuer.rstrip('/') return normalized in self.issuers # Global OIDC config instance oidc_config = OIDCConfig() # Per-issuer JWKS cache _jwks_cache: Dict[str, Dict] = {} def get_jwks_for_issuer(issuer: str) -> Dict: """ Fetch JSON Web Key Set (JWKS) for a specific issuer. Cached per-issuer to avoid repeated requests. Cache is cleared on server restart. Args: issuer: The token issuer URL Returns: JWKS dictionary containing public keys for token verification Raises: HTTPException: If JWKS fetch fails """ if not oidc_config.enabled: return {} normalized_issuer = issuer.rstrip('/') # Return cached JWKS if available if normalized_issuer in _jwks_cache: return _jwks_cache[normalized_issuer] jwks_uri = oidc_config.get_jwks_uri(normalized_issuer) try: logger.debug(f"Fetching JWKS from {jwks_uri}") response = httpx.get(jwks_uri, timeout=10.0) response.raise_for_status() jwks = response.json() logger.info(f"JWKS fetched successfully for {normalized_issuer} ({len(jwks.get('keys', []))} keys)") _jwks_cache[normalized_issuer] = jwks return jwks except Exception as e: logger.error(f"Failed to fetch JWKS from {jwks_uri}: {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, return a default local user if not oidc_config.enabled: logger.debug("OIDC disabled - using local user") return { "sub": "local-user", "email": "local@localhost", "preferred_username": "local", "name": "Local User", "groups": ["admin"], "auth_method": "local" } # 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: # First, decode token without verification to get issuer and key ID unverified_header = jwt.get_unverified_header(token) unverified_claims = jwt.get_unverified_claims(token) kid = unverified_header.get("kid") token_issuer = unverified_claims.get("iss", "") token_audience = unverified_claims.get("aud", "") if not kid: raise HTTPException(status_code=401, detail="Invalid token format") # Validate issuer is in allowed list logger.debug(f"Token issuer: {token_issuer}, allowed issuers: {oidc_config.issuers}") if not oidc_config.is_valid_issuer(token_issuer): logger.warning(f"Invalid token issuer: {token_issuer} (allowed: {oidc_config.issuers})") raise HTTPException(status_code=401, detail="Invalid token issuer") # Validate audience is in allowed list if token_audience not in oidc_config.audiences: logger.warning(f"Invalid token audience: {token_audience} (allowed: {oidc_config.audiences})") raise HTTPException(status_code=401, detail="Invalid token audience") # Get JWKS for this specific issuer jwks = get_jwks_for_issuer(token_issuer) 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 using the token's actual issuer and audience payload = jwt.decode( token, rsa_key, algorithms=["RS256"], audience=token_audience, # Use the token's audience (already validated) issuer=token_issuer, # Use the token's issuer (already validated) ) user_email = payload.get("email", "unknown") logger.info(f"Authenticated user: {user_email} (issuer: {token_issuer})") 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, local user if OIDC disabled, None otherwise """ # If OIDC is disabled, return the local user if not oidc_config.enabled: return { "sub": "local-user", "email": "local@localhost", "preferred_username": "local", "name": "Local User", "groups": ["admin"], "auth_method": "local" } if not credentials: logger.debug("No credentials provided for optional auth") return None try: return await get_current_user(credentials) except HTTPException as e: # Invalid token - log and return None instead of raising logger.warning(f"Optional auth failed: {e.detail}") 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 async def get_current_user_or_forward_auth( request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Security(security) ) -> Dict: """ Combined auth: Try forward auth headers first, then JWT Bearer token. Supports both: - Web clients via NPM forward auth (X-authentik-* headers from proxy) - Mobile/native clients via OIDC JWT Bearer tokens This is the preferred dependency for /auth/users/me and similar endpoints that need to work with both web (cookie-based via NPM) and mobile (token-based). Args: request: FastAPI request object containing headers credentials: HTTP Bearer token from Authorization header Returns: User claims dictionary with at minimum: sub, email, name, groups, auth_method Raises: HTTPException 401: If neither forward auth headers nor valid JWT provided """ # 1. Try forward auth headers first (web via NPM) username = request.headers.get("x-authentik-username") email = request.headers.get("x-authentik-email") if username and email: # Forward auth headers present - use them groups = request.headers.get("x-authentik-groups", "") name = request.headers.get("x-authentik-name", username) uid = request.headers.get("x-authentik-uid") user_info = { "sub": uid, # Use authentik UID as subject (for user lookup) "email": email, "preferred_username": username, "name": name, "groups": [g.strip() for g in groups.split(",")] if groups else [], "auth_method": "forward_auth" } logger.info(f"Authenticated via forward auth: {email}") return user_info # 2. Fall back to JWT Bearer token (mobile/native) return await get_current_user(credentials) # ============================================================================= # Permission-Based Access Control # ============================================================================= def _parse_permission(permission: str) -> tuple[str, str, str]: """ Parse a permission string into (domain, category, action) Supports formats: - domain.category:action (full): "control-room.general:admin" - domain:action (shorthand): "control-room:admin" -> ("control-room", "general", "admin") Returns: Tuple of (domain, category, action) Raises: ValueError: If permission format is invalid """ # Split on colon first to get action if ":" not in permission: raise ValueError(f"Invalid permission format (missing ':'): {permission}") location, action = permission.rsplit(":", 1) # Split location on dot to get domain and category if "." in location: domain, category = location.split(".", 1) else: # Shorthand: domain:action -> domain.general:action domain = location category = DEFAULT_CATEGORY return domain, category, action def _action_satisfies(user_action: str, required_action: str) -> bool: """ Check if user's action level satisfies the required action Due to hierarchy, admin satisfies editor, editor satisfies user, etc. Args: user_action: The action the user has required_action: The action required for access Returns: True if user's action is >= required action """ user_level = ACTION_HIERARCHY.get(user_action, 0) required_level = ACTION_HIERARCHY.get(required_action, 0) return user_level >= required_level def _user_has_permission( user_permissions: List[str], required_domain: str, required_category: str, required_action: str, ) -> bool: """ Check if user has a permission that satisfies the requirement Checks: 1. Exact match: domain.category:action 2. Domain-wide: domain.general:action (if category != general) 3. Global admin: admin.general:admin (superuser) Args: user_permissions: List of user's permission strings required_domain: Required domain required_category: Required category required_action: Required action Returns: True if user has sufficient permission """ for perm in user_permissions: try: dom, cat, act = _parse_permission(perm) except ValueError: continue # Global admin (admin.general:admin) grants all permissions if dom == "admin" and cat == "general" and act == "admin": return True # Check if this permission covers the requirement if dom == required_domain: # Exact category match if cat == required_category and _action_satisfies(act, required_action): return True # Domain-wide permission (general category) covers all categories in domain if cat == "general" and _action_satisfies(act, required_action): return True return False def _extract_permissions_from_groups(groups: List[str]) -> List[str]: """ Extract permission strings from Authentik group names Authentik groups follow naming: tatlock-{domain}-{category}-{action} or shorthand: tatlock-{domain}-{action} (implies category=general) Examples: - tatlock-control-room-general-admin -> control-room.general:admin - tatlock-media-viewer -> media.general:viewer (shorthand) - tatlock-tools-dns-user -> tools.dns:user Args: groups: List of Authentik group names Returns: List of permission strings """ permissions = [] for group in groups: if not group.startswith("tatlock-"): continue # Remove prefix parts = group[8:].split("-") # Remove "tatlock-" if len(parts) >= 3: # Could be domain-category-action or domain-with-hyphen-action # Try to find a valid action at the end action = parts[-1] if action in ACTION_HIERARCHY: # Check if domain-category or single domain with hyphen remaining = parts[:-1] # Try to find known domain (greedy match from start) for i in range(len(remaining), 0, -1): potential_domain = "-".join(remaining[:i]) if potential_domain in VALID_DOMAINS: category_parts = remaining[i:] category = "-".join(category_parts) if category_parts else DEFAULT_CATEGORY permissions.append(f"{potential_domain}.{category}:{action}") break elif len(parts) == 2: # Shorthand: domain-action (domain might have hyphen) action = parts[-1] if action in ACTION_HIERARCHY: domain = parts[0] if domain in VALID_DOMAINS: permissions.append(f"{domain}.{DEFAULT_CATEGORY}:{action}") return permissions def require_permission( domain: str, action: str, category: str = DEFAULT_CATEGORY, ) -> Callable: """ Dependency factory for permission-based access control Creates a FastAPI dependency that checks if the current user has the required permission. Considers action hierarchy and global admin. Usage: @router.get("/containers") async def list_containers( user: Dict = Depends(require_permission("control-room", "viewer")) ): ... @router.delete("/container/{id}") async def delete_container( user: Dict = Depends(require_permission("control-room", "admin")) ): ... Args: domain: Permission domain (e.g., "control-room", "media") action: Required action level (viewer, user, editor, admin) category: Permission category within domain, defaults to "general" Returns: FastAPI dependency function """ perm_str = f"{domain}.{category}:{action}" async def permission_checker( user: Optional[Dict] = Depends(get_current_user) ) -> Dict: """Check if user has required permission""" # If OIDC disabled, allow all (local dev mode) if not oidc_config.enabled: logger.debug(f"OIDC disabled - allowing {perm_str}") return user or {"email": "local", "groups": ["admin"]} if user is None: raise HTTPException( status_code=401, detail="Authentication required", headers={"WWW-Authenticate": "Bearer"}, ) # Extract permissions from user's groups groups = user.get("groups", []) permissions = _extract_permissions_from_groups(groups) # Check if user has required permission if _user_has_permission(permissions, domain, category, action): logger.debug(f"User {user.get('email')} granted {perm_str}") return user # Permission denied user_email = user.get("email", "unknown") logger.warning( f"User {user_email} denied {perm_str} " f"(groups: {groups}, permissions: {permissions})" ) raise HTTPException( status_code=403, detail=f"Permission required: {perm_str}", ) return permission_checker def require_any_permission(*required_permissions: str) -> Callable: """ Dependency factory requiring any one of multiple permissions Useful for endpoints accessible to multiple roles. Usage: @router.get("/shared-resource") async def get_shared( user: Dict = Depends(require_any_permission( "control-room:viewer", "media:viewer", )) ): ... Args: *required_permissions: Permission strings (domain.category:action or domain:action) Returns: FastAPI dependency function """ async def permission_checker( user: Optional[Dict] = Depends(get_current_user) ) -> Dict: """Check if user has any of the required permissions""" # If OIDC disabled, allow all if not oidc_config.enabled: return user or {"email": "local", "groups": ["admin"]} if user is None: raise HTTPException( status_code=401, detail="Authentication required", headers={"WWW-Authenticate": "Bearer"}, ) groups = user.get("groups", []) permissions = _extract_permissions_from_groups(groups) # Check each required permission for perm in required_permissions: try: dom, cat, act = _parse_permission(perm) if _user_has_permission(permissions, dom, cat, act): logger.debug(f"User {user.get('email')} granted via {perm}") return user except ValueError: logger.warning(f"Invalid permission format: {perm}") continue # None matched user_email = user.get("email", "unknown") logger.warning( f"User {user_email} denied (required any of: {required_permissions})" ) raise HTTPException( status_code=403, detail=f"One of these permissions required: {', '.join(required_permissions)}", ) return permission_checker