Files
ITWorx_MCP_Hub-Public/services/control_api/auth/config.py
T

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