"""Optional, fail-closed OIDC bearer authorization for ServiceMap APIs.""" from __future__ import annotations import os from dataclasses import dataclass from functools import lru_cache from urllib.parse import urlparse import httpx import jwt from fastapi import HTTPException, Request, status class AuthorizationError(RuntimeError): pass _ROLE_ORDER = {"viewer": 1, "operator": 2, "admin": 3} def _csv(name: str) -> set[str]: return {item.strip().casefold() for item in os.getenv(name, "").split(",") if item.strip()} @dataclass(frozen=True) class AuthConfig: mode: str issuer: str | None = None audience: str | None = None role_claims: tuple[str, ...] = ("groups", "roles") viewer_groups: frozenset[str] = frozenset() operator_groups: frozenset[str] = frozenset() admin_groups: frozenset[str] = frozenset() @classmethod def from_environment(cls) -> AuthConfig: mode = os.getenv("SERVICEMAP_AUTH_MODE", "disabled").strip().casefold() or "disabled" if mode == "disabled": return cls(mode="disabled") if mode != "oidc": raise AuthorizationError("SERVICEMAP_AUTH_MODE must be 'disabled' or 'oidc'") issuer = os.getenv("SERVICEMAP_OIDC_ISSUER", "").strip().rstrip("/") audience = os.getenv("SERVICEMAP_OIDC_AUDIENCE", "").strip() parsed = urlparse(issuer) local_http = os.getenv("SERVICEMAP_AUTH_ALLOW_HTTP_FOR_LOCAL", "false").casefold() == "true" if ( not issuer or not audience or not parsed.netloc or (parsed.scheme != "https" and not local_http) ): raise AuthorizationError("OIDC mode requires a valid HTTPS issuer and audience") claims = tuple( item.strip() for item in os.getenv("SERVICEMAP_OIDC_ROLE_CLAIMS", "groups,roles").split(",") if item.strip() ) return cls( mode=mode, issuer=issuer, audience=audience, role_claims=claims or ("groups", "roles"), viewer_groups=frozenset(_csv("SERVICEMAP_OIDC_VIEWER_GROUPS")), operator_groups=frozenset(_csv("SERVICEMAP_OIDC_OPERATOR_GROUPS")), admin_groups=frozenset(_csv("SERVICEMAP_OIDC_ADMIN_GROUPS")), ) @lru_cache(maxsize=8) def _jwks_client(issuer: str) -> jwt.PyJWKClient: try: document = httpx.get(f"{issuer}/.well-known/openid-configuration", timeout=5.0).json() jwks_uri = str(document.get("jwks_uri", "")) if isinstance(document, dict) else "" except (httpx.HTTPError, ValueError) as exc: raise AuthorizationError("OIDC discovery is unavailable") from exc parsed = urlparse(jwks_uri) issuer_host = urlparse(issuer).hostname if not parsed.netloc or parsed.scheme != "https" or parsed.hostname != issuer_host: raise AuthorizationError("OIDC discovery returned an invalid JWKS URI") return jwt.PyJWKClient(jwks_uri, cache_keys=True, lifespan=300) def _claim_values(claims: dict[str, object], names: tuple[str, ...]) -> set[str]: values: set[str] = set() for name in names: raw = claims.get(name) if isinstance(raw, str): values.add(raw.casefold()) elif isinstance(raw, list): values.update(str(item).casefold() for item in raw if isinstance(item, (str, int))) return values def _roles_for(claims: dict[str, object], config: AuthConfig) -> set[str]: values = _claim_values(claims, config.role_claims) roles: set[str] = set() # Explicit role claims remain useful with a constrained issuer; group # mapping gives operators a stable least-privilege model in Authentik. roles.update(value for value in values if value in _ROLE_ORDER) if values & config.viewer_groups: roles.add("viewer") if values & config.operator_groups: roles.add("operator") if values & config.admin_groups: roles.add("admin") if "admin" in roles: roles.update({"operator", "viewer"}) elif "operator" in roles: roles.add("viewer") return roles def verify_bearer(token: str, config: AuthConfig) -> set[str]: if not config.issuer or not config.audience: raise AuthorizationError("OIDC configuration is incomplete") try: signing_key = _jwks_client(config.issuer).get_signing_key_from_jwt(token) claims = jwt.decode( token, signing_key.key, algorithms=[signing_key.algorithm_name], audience=config.audience, issuer=config.issuer, options={"require": ["exp", "sub", "iss", "aud"]}, ) except jwt.PyJWTError as exc: raise AuthorizationError("Bearer token verification failed") from exc if not isinstance(claims, dict): raise AuthorizationError("Bearer token claims are invalid") return _roles_for(claims, config) def required_role(request: Request) -> str: if request.method in {"GET", "HEAD", "OPTIONS"}: return "viewer" path = request.url.path if path.startswith( ("/api/v1/config", "/api/v1/discovery", "/api/v1/docker", "/api/v1/maintenance") ): return "admin" return "operator" def authorize_request(request: Request) -> None: """Authorize API requests; health and static content remain reachable. Disabled mode preserves v1.5 behavior intentionally. Enabling OIDC with incomplete configuration fails closed on the API while `/health` remains available for recovery and deployment diagnostics. """ if request.url.path == "/health" or not request.url.path.startswith("/api/"): return try: config = AuthConfig.from_environment() except AuthorizationError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc) ) from exc if config.mode == "disabled": return header = request.headers.get("Authorization", "") if not header.startswith("Bearer "): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Bearer authentication is required" ) try: roles = verify_bearer(header.removeprefix("Bearer ").strip(), config) except AuthorizationError as exc: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc if ( _ROLE_ORDER.get(max(roles, key=lambda item: _ROLE_ORDER[item]) if roles else "", 0) < _ROLE_ORDER[required_role(request)] ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient ServiceMap role" )