161 lines
6.7 KiB
Python
161 lines
6.7 KiB
Python
"""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
|