- Changed oidc_audience (string) to oidc_audiences (list) - Now accepts tokens with audience: core-api, tatlock-ui, tatlock - Fixes environment endpoint returning "default" user when using tatlock-ui token (audience mismatch was causing JWT claims error) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
337 lines
10 KiB
Python
337 lines
10 KiB
Python
"""
|
|
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
|