Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,160 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user