Files
core-api/src/domains/auth/oidc.py
T
Jeroen SchweitzerandClaude Opus 4.5 dd0997679f
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m11s
fix: /auth/users/me now supports NPM forward auth headers
Added get_current_user_or_forward_auth() combined dependency that:
- First checks for X-authentik-* headers from NPM forward auth (web)
- Falls back to JWT Bearer token validation (mobile/native)

This fixes web authentication where browsers don't send Bearer tokens
but rely on NPM's forward auth proxy to pass user info via headers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:24:41 +01:00

739 lines
23 KiB
Python

"""
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
- ai.ollama:user - User-level access to Ollama specifically (future)
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.issuer = ""
self.audience = ""
self.jwks_uri = ""
def configure(self, enabled: bool, issuer: str, audience: str):
"""Configure OIDC settings"""
self.enabled = enabled
self.issuer = issuer
self.audience = audience
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/"
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}")
# 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, 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:
# 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
payload = jwt.decode(
token,
rsa_key,
algorithms=["RS256"],
audience=oidc_config.audience,
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, 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:
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
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-ai-ollama-user -> ai.ollama: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