"""Environment-only OIDC configuration.""" from __future__ import annotations import json import os from collections.abc import Mapping from pathlib import Path from typing import Literal, cast from pydantic import ( AnyHttpUrl, BaseModel, ConfigDict, Field, TypeAdapter, field_validator, model_validator, ) from packages.hub_runtime import SecretFileError, read_secret_file from .errors import ConfigurationError from .models import ManagementRole class OIDCSettings(BaseModel): """Validated configuration; secret values are never persisted in this model.""" model_config = ConfigDict(extra="forbid", frozen=True) issuer: str = Field(min_length=1, max_length=2048) audience: str = Field(min_length=1, max_length=256) client_id: str = Field(min_length=1, max_length=256) redirect_uri: AnyHttpUrl client_secret_env: str | None = Field(default=None, min_length=1, max_length=256) client_secret_file: Path | None = None session_signing_key_file: Path | None = None session_ttl_seconds: int = Field(default=28800, ge=60, le=28800) clock_skew_seconds: int = Field(default=60, ge=0, le=120) revocation_ttl_seconds: int = Field(default=60, ge=1, le=60) cookie_name: str = Field(default="hub_session", pattern=r"^[A-Za-z][A-Za-z0-9_]{0,63}$") same_site: Literal["strict", "lax"] = "strict" secure_cookie: bool = True allowed_algorithms: tuple[str, ...] = ("RS256",) role_group_map: dict[str, ManagementRole] = Field(default_factory=dict) @field_validator("issuer") @classmethod def preserve_exact_https_issuer(cls, value: str) -> str: parsed = TypeAdapter(AnyHttpUrl).validate_python(value) if parsed.scheme != "https" or parsed.username is not None or parsed.password is not None: raise ValueError("OIDC issuer must be an HTTPS URL without userinfo") return value @field_validator("secure_cookie") @classmethod def require_secure_cookie(cls, value: bool) -> bool: if not value: raise ValueError("session cookies must be Secure") return value @field_validator("allowed_algorithms") @classmethod def validate_algorithms(cls, value: tuple[str, ...]) -> tuple[str, ...]: if not value or any( algorithm in {"none", "HS256", "HS384", "HS512"} for algorithm in value ): raise ValueError("only asymmetric JWT algorithms are allowed") return value @model_validator(mode="after") def one_client_secret_source(self) -> OIDCSettings: if self.client_secret_env and self.client_secret_file: raise ValueError("configure only one OIDC client secret source") return self @classmethod def from_env(cls, environ: Mapping[str, str] | None = None) -> OIDCSettings: values = environ if environ is not None else os.environ required = { "issuer": values.get("HUB_OIDC_ISSUER"), "audience": values.get("HUB_OIDC_AUDIENCE"), "client_id": values.get("HUB_OIDC_CLIENT_ID"), "redirect_uri": values.get("HUB_OIDC_REDIRECT_URI"), } missing = [key for key, value in required.items() if not value] if missing: raise ConfigurationError("missing required OIDC configuration") role_map: dict[str, ManagementRole] = {} raw_role_map = values.get("HUB_OIDC_ROLE_GROUP_MAP") if raw_role_map: try: decoded = json.loads(raw_role_map) if not isinstance(decoded, dict): raise ValueError role_map = { str(group): ManagementRole(str(role)) for group, role in decoded.items() } except (TypeError, ValueError, json.JSONDecodeError) as exc: raise ConfigurationError("invalid OIDC role mapping") from exc try: return cls( issuer=cast(str, required["issuer"]), audience=cast(str, required["audience"]), client_id=cast(str, required["client_id"]), redirect_uri=cast(AnyHttpUrl, required["redirect_uri"]), client_secret_env=values.get("HUB_OIDC_CLIENT_SECRET_ENV"), client_secret_file=( Path(values["HUB_OIDC_CLIENT_SECRET_FILE"]) if values.get("HUB_OIDC_CLIENT_SECRET_FILE") else None ), session_signing_key_file=( Path(values["HUB_SESSION_SIGNING_KEY_FILE"]) if values.get("HUB_SESSION_SIGNING_KEY_FILE") else None ), session_ttl_seconds=int(values.get("HUB_SESSION_TTL_SECONDS", "28800")), clock_skew_seconds=int(values.get("HUB_OIDC_CLOCK_SKEW_SECONDS", "60")), revocation_ttl_seconds=int(values.get("HUB_REVOCATION_TTL_SECONDS", "60")), cookie_name=values.get("HUB_SESSION_COOKIE_NAME", "hub_session"), same_site=cast( Literal["strict", "lax"], values.get("HUB_SESSION_SAME_SITE", "strict") ), secure_cookie=values.get("HUB_SESSION_SECURE", "true").lower() == "true", allowed_algorithms=tuple( algorithm.strip() for algorithm in values.get("HUB_OIDC_ALLOWED_ALGORITHMS", "RS256").split(",") if algorithm.strip() ), role_group_map=role_map, ) except (TypeError, ValueError) as exc: raise ConfigurationError("invalid OIDC configuration") from exc def client_secret(self, environ: Mapping[str, str] | None = None) -> str | None: """Read a secret only at the outbound token-exchange boundary.""" if self.client_secret_file: try: return read_secret_file(self.client_secret_file) except SecretFileError as exc: raise ConfigurationError("configured OIDC client secret is unavailable") from exc if not self.client_secret_env: return None value = (environ or os.environ).get(self.client_secret_env) if not value: raise ConfigurationError("configured OIDC client secret is unavailable") return value def session_signing_key(self) -> bytes | None: """Read the server-side session key only while constructing the session store.""" if not self.session_signing_key_file: return None try: return read_secret_file(self.session_signing_key_file).encode("utf-8") except SecretFileError as exc: raise ConfigurationError("configured session key is unavailable") from exc