Phase 2: Docker and OIDC auth
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"""OIDC/JWT authentication helpers for backend API requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from jwt import PyJWKClient
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
|
||||
from media_library_viewer_api.config import Settings, get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXEMPT_PATHS = {
|
||||
"/api/health",
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/redoc",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_issuer_url(issuer_url: str) -> str:
|
||||
return issuer_url.rstrip("/") + "/" if issuer_url else ""
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_oidc_metadata(issuer_url: str) -> dict[str, Any]:
|
||||
normalized = _normalize_issuer_url(issuer_url)
|
||||
discovery_url = urljoin(normalized, ".well-known/openid-configuration")
|
||||
response = requests.get(discovery_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
if not isinstance(metadata, dict):
|
||||
raise RuntimeError("OIDC discovery response was not a JSON object")
|
||||
return metadata
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_jwk_client(jwks_url: str) -> PyJWKClient:
|
||||
return PyJWKClient(jwks_url)
|
||||
|
||||
|
||||
def _split_audience(audience: str) -> list[str]:
|
||||
return [item.strip() for item in audience.split(",") if item.strip()]
|
||||
|
||||
|
||||
def validate_auth_settings(settings: Settings) -> None:
|
||||
if not settings.auth_enabled:
|
||||
return
|
||||
if not settings.oidc_issuer_url:
|
||||
raise RuntimeError("AUTH_ENABLED is true but OIDC_ISSUER_URL is not configured")
|
||||
if not settings.oidc_audience:
|
||||
raise RuntimeError("AUTH_ENABLED is true but OIDC_AUDIENCE is not configured")
|
||||
|
||||
|
||||
def validate_bearer_jwt(authorization: str | None, settings: Settings | None = None) -> dict[str, Any]:
|
||||
settings = settings or get_settings()
|
||||
validate_auth_settings(settings)
|
||||
if not settings.auth_enabled:
|
||||
return {}
|
||||
|
||||
if not authorization:
|
||||
raise PermissionError("Missing Authorization header")
|
||||
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() != "bearer" or not token.strip():
|
||||
raise PermissionError("Authorization header must use Bearer token format")
|
||||
|
||||
issuer_url = _normalize_issuer_url(settings.oidc_issuer_url)
|
||||
metadata = get_oidc_metadata(issuer_url)
|
||||
jwks_url = settings.oidc_jwks_url.strip() or str(metadata.get("jwks_uri") or "")
|
||||
if not jwks_url:
|
||||
raise RuntimeError("OIDC discovery metadata does not include a JWKS URL")
|
||||
|
||||
jwk_client = get_jwk_client(jwks_url)
|
||||
signing_key = jwk_client.get_signing_key_from_jwt(token).key
|
||||
audience = _split_audience(settings.oidc_audience)
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
signing_key,
|
||||
algorithms=list(metadata.get("id_token_signing_alg_values_supported") or ["RS256"]),
|
||||
audience=audience[0] if len(audience) == 1 else audience,
|
||||
issuer=issuer_url,
|
||||
leeway=int(settings.oidc_clock_skew_seconds or 0),
|
||||
options={"require": ["exp", "iss"]},
|
||||
)
|
||||
return claims
|
||||
|
||||
|
||||
async def require_jwt_auth(request: Request, call_next):
|
||||
settings = get_settings()
|
||||
path = request.url.path
|
||||
if request.method == "OPTIONS" or path in EXEMPT_PATHS or not path.startswith("/api"):
|
||||
return await call_next(request)
|
||||
|
||||
try:
|
||||
claims = validate_bearer_jwt(request.headers.get("authorization"), settings)
|
||||
except PermissionError as exc:
|
||||
logger.warning("JWT auth rejected path=%s reason=%s", path, exc)
|
||||
return JSONResponse(status_code=401, content={"detail": str(exc)})
|
||||
except InvalidTokenError as exc:
|
||||
logger.warning("JWT auth token invalid path=%s error=%s", path, exc)
|
||||
return JSONResponse(status_code=401, content={"detail": "Invalid bearer token"})
|
||||
except Exception as exc: # pragma: no cover - safety net for OIDC/JWKS failures
|
||||
logger.exception("JWT auth validation failed path=%s", path)
|
||||
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||
|
||||
request.state.jwt_claims = claims
|
||||
request.state.jwt_subject = claims.get("sub") if isinstance(claims, dict) else None
|
||||
return await call_next(request)
|
||||
Reference in New Issue
Block a user