Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:36:21 +02:00
commit cb5be898ea
663 files changed
+82674

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
"""OIDC identity, browser BFF, and machine-principal boundaries."""
from .bff import BFFService
from .config import OIDCSettings
from .machine import (
MachineClientBinding,
MachinePrincipalAuthenticator,
PrincipalMapper,
RevocationCache,
)
from .models import AuthenticatedPrincipal, ManagementRole, PrincipalKind, SessionMetadata
from .provider import OIDCProviderClient
from .router import build_auth_router
__all__ = [
"AuthenticatedPrincipal",
"BFFService",
"MachineClientBinding",
"MachinePrincipalAuthenticator",
"PrincipalMapper",
"ManagementRole",
"OIDCProviderClient",
"OIDCSettings",
"PrincipalKind",
"RevocationCache",
"build_auth_router",
"SessionMetadata",
]
+255
View File
@@ -0,0 +1,255 @@
"""Same-origin browser BFF with server-side opaque sessions."""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import threading
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from http.cookies import SimpleCookie
from .config import OIDCSettings
from .errors import CSRFError, InvalidState, SessionExpired
from .models import (
AuthenticatedPrincipal,
PrincipalKind,
SessionCookie,
SessionMetadata,
TokenSet,
ValidatedClaims,
)
from .provider import OIDCProviderClient
def _hash(value: str, key: bytes | None = None) -> str:
payload = value.encode("ascii")
return (
hmac.new(key, payload, hashlib.sha256).hexdigest()
if key
else hashlib.sha256(payload).hexdigest()
)
def _pkce_challenge(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
@dataclass(frozen=True)
class _PendingLogin:
nonce: str
code_verifier: str
expires_at: datetime
@dataclass(frozen=True)
class _SessionRecord:
principal: AuthenticatedPrincipal
access_token: str
refresh_token: str | None
csrf_token: str
expires_at: datetime
class SessionStore:
"""Thread-safe in-memory session/CSRF store -- this IS the production
implementation, not a placeholder. It is a deliberate, documented,
single-instance-only design (docs/02-security/SESSION_STORE_DECISION.md):
control-api must run as exactly one process/replica, every session and
every CSRF token is lost on restart (users simply re-authenticate), and
horizontal scaling requires a real shared store first. The Compose
single-instance constraint this depends on is enforced statically by
scripts/compose_release_check.py, not just documented."""
def __init__(
self,
*,
ttl_seconds: int,
cookie_name: str,
same_site: str,
secure: bool,
hash_key: bytes | None = None,
) -> None:
self.ttl_seconds = ttl_seconds
self.cookie_name = cookie_name
self.same_site = same_site
self.secure = secure
self._hash_key = hash_key
self._records: dict[str, _SessionRecord] = {}
self._lock = threading.RLock()
def create(
self,
principal: AuthenticatedPrincipal,
token_set: TokenSet,
*,
now: datetime | None = None,
) -> SessionCookie:
reference = now or datetime.now(UTC)
expires_at = min(
principal.expires_at,
reference + timedelta(seconds=self.ttl_seconds),
)
if expires_at <= reference:
raise SessionExpired()
session_value = secrets.token_urlsafe(32)
csrf_token = secrets.token_urlsafe(24)
record = _SessionRecord(
principal=principal,
access_token=token_set.access_token,
refresh_token=token_set.refresh_token,
csrf_token=csrf_token,
expires_at=expires_at,
)
with self._lock:
self._records[_hash(session_value, self._hash_key)] = record
metadata = SessionMetadata(
subject=principal.subject,
client_id=principal.client_id,
kind=principal.kind,
roles=principal.roles,
expires_at=expires_at,
)
return SessionCookie(
value=session_value,
csrf_token=csrf_token,
metadata=metadata,
set_cookie=self._cookie(session_value, expires_at),
)
def get(self, session_value: str | None, *, now: datetime | None = None) -> _SessionRecord:
if not session_value or len(session_value) > 256:
raise SessionExpired()
reference = now or datetime.now(UTC)
key = _hash(session_value, self._hash_key)
with self._lock:
record = self._records.get(key)
if record is None:
raise SessionExpired()
if record.expires_at <= reference:
self._records.pop(key, None)
raise SessionExpired()
return record
def logout(self, session_value: str | None) -> None:
if session_value:
with self._lock:
self._records.pop(_hash(session_value, self._hash_key), None)
def _cookie(self, value: str, expires_at: datetime) -> str:
cookie = SimpleCookie()
cookie[self.cookie_name] = value
morsel = cookie[self.cookie_name]
morsel["httponly"] = True
morsel["secure"] = self.secure
morsel["samesite"] = self.same_site
morsel["path"] = "/"
morsel["expires"] = expires_at.astimezone(UTC).strftime("%a, %d %b %Y %H:%M:%S GMT")
return morsel.OutputString()
def clear_cookie(self) -> str:
cookie = SimpleCookie()
cookie[self.cookie_name] = ""
morsel = cookie[self.cookie_name]
morsel["httponly"] = True
morsel["secure"] = self.secure
morsel["samesite"] = self.same_site
morsel["path"] = "/"
morsel["max-age"] = 0
return morsel.OutputString()
class BFFService:
"""Coordinates PKCE login, opaque sessions, CSRF, logout, and safe metadata."""
def __init__(
self,
settings: OIDCSettings,
provider: OIDCProviderClient,
*,
principal_mapper: Callable[[ValidatedClaims], AuthenticatedPrincipal],
) -> None:
self.settings = settings
self.provider = provider
self.principal_mapper = principal_mapper
self.sessions = SessionStore(
ttl_seconds=settings.session_ttl_seconds,
cookie_name=settings.cookie_name,
same_site=settings.same_site,
secure=settings.secure_cookie,
hash_key=settings.session_signing_key(),
)
self._pending: dict[str, _PendingLogin] = {}
self._lock = threading.RLock()
def begin_login(self, *, now: datetime | None = None) -> str:
reference = now or datetime.now(UTC)
state = secrets.token_urlsafe(32)
pending = _PendingLogin(
nonce=secrets.token_urlsafe(24),
code_verifier=secrets.token_urlsafe(48),
expires_at=reference + timedelta(minutes=10),
)
with self._lock:
self._pending[_hash(state)] = pending
return self.provider.build_authorization_url(
state=state,
nonce=pending.nonce,
code_challenge=_pkce_challenge(pending.code_verifier),
)
def complete_login(
self,
*,
code: str,
state: str,
now: datetime | None = None,
) -> SessionCookie:
reference = now or datetime.now(UTC)
with self._lock:
pending = self._pending.pop(_hash(state), None)
if pending is None or pending.expires_at <= reference:
raise InvalidState()
token_set = self.provider.exchange_code(code=code, code_verifier=pending.code_verifier)
raw_token = token_set.id_token or token_set.access_token
claims = self.provider.validate_token(raw_token, now=reference)
if token_set.id_token:
if claims.nonce != pending.nonce:
raise InvalidState()
principal = self.principal_mapper(claims)
if principal.kind is not PrincipalKind.MANAGEMENT_USER:
raise InvalidState()
return self.sessions.create(principal, token_set, now=reference)
def session_metadata(
self, session_value: str | None, *, now: datetime | None = None
) -> SessionMetadata:
record = self.sessions.get(session_value, now=now)
return SessionMetadata(
subject=record.principal.subject,
client_id=record.principal.client_id,
kind=record.principal.kind,
roles=record.principal.roles,
expires_at=record.expires_at,
)
def require_session(
self, session_value: str | None, *, now: datetime | None = None
) -> AuthenticatedPrincipal:
return self.sessions.get(session_value, now=now).principal
def verify_csrf(self, session_value: str | None, csrf_token: str | None) -> None:
"""Require the session-bound token for every unsafe browser request."""
record = self.sessions.get(session_value)
if not csrf_token or not secrets.compare_digest(record.csrf_token, csrf_token):
raise CSRFError()
def logout(self, session_value: str | None, csrf_token: str | None) -> str:
self.verify_csrf(session_value, csrf_token)
self.sessions.logout(session_value)
return self.sessions.clear_cookie()
+160
View File
@@ -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
+58
View File
@@ -0,0 +1,58 @@
"""Safe, stable authentication errors."""
from __future__ import annotations
class AuthError(Exception):
"""An error whose public code/message is safe to expose to a caller."""
code = "authentication_failed"
public_message = "Authentication failed."
def __init__(self, message: str | None = None) -> None:
super().__init__(message or self.public_message)
class ConfigurationError(AuthError):
code = "authentication_configuration_error"
public_message = "Authentication is not configured."
class ProviderUnavailable(AuthError):
code = "identity_provider_unavailable"
public_message = "The identity provider is temporarily unavailable."
class InvalidToken(AuthError):
code = "invalid_token"
public_message = "The supplied credential is invalid."
class TokenExchangeError(AuthError):
code = "token_exchange_failed"
public_message = "Authentication could not be completed."
class InvalidState(AuthError):
code = "invalid_auth_state"
public_message = "Authentication could not be completed."
class CSRFError(AuthError):
code = "csrf_failed"
public_message = "The request could not be verified."
class SessionExpired(AuthError):
code = "session_expired"
public_message = "The session has expired."
class UnknownMachinePrincipal(AuthError):
code = "unknown_machine_principal"
public_message = "The machine client is not registered."
class RevokedMachinePrincipal(AuthError):
code = "revoked_machine_principal"
public_message = "The machine client is disabled."
+214
View File
@@ -0,0 +1,214 @@
"""Deterministic offline OIDC provider used by integration and E2E tests."""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any, cast
from urllib.parse import parse_qs, urlparse
from uuid import uuid4
import httpx
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
@dataclass
class _FakeKey:
kid: str
private_key: Any
class FakeOIDCProvider:
"""A local MockTransport provider; it never uses real credentials or network I/O."""
def __init__(
self,
*,
issuer: str = "https://fake.oidc.test",
audience: str = "itworx-mcp-hub",
user_subject: str = "user-management-1",
user_groups: tuple[str, ...] = ("hub-viewer",),
persona_groups: dict[str, tuple[str, ...]] | None = None,
token_ttl_seconds: int = 300,
) -> None:
self.issuer = issuer
self._base_url = issuer.rstrip("/")
self.audience = audience
self.user_subject = user_subject
self.user_groups = user_groups
self._token_ttl_seconds = token_ttl_seconds
# Test-only: lets a real browser flow (GET /authorize, unlike
# authorize() which is called directly from Python) select which
# group claim to issue via a "persona" query param, so E2E tests can
# exercise multiple roles against one running fake-provider process
# without touching the real BFF login route to add a hint parameter.
self._persona_groups = persona_groups or {}
self._key = self._new_key("fake-key-1")
self._codes: dict[str, tuple[str, str, tuple[str, ...]]] = {}
@staticmethod
def _new_key(kid: str) -> _FakeKey:
return _FakeKey(
kid=kid,
private_key=rsa.generate_private_key(public_exponent=65537, key_size=2048),
)
def rotate_keys(self, kid: str = "fake-key-rotated") -> None:
self._key = self._new_key(kid)
def transport(self) -> httpx.MockTransport:
return httpx.MockTransport(self._handle)
def asgi_app(self) -> Any:
"""A real ASGI app serving the same endpoints as transport(), for
E2E harnesses where an actual browser must navigate to /authorize
(a MockTransport only intercepts server-to-server httpx calls)."""
from starlette.applications import Starlette
from starlette.requests import Request as StarletteRequest
from starlette.responses import Response as StarletteResponse
from starlette.routing import Route
async def endpoint(request: StarletteRequest) -> StarletteResponse:
body = await request.body()
httpx_request = httpx.Request(
request.method,
str(request.url),
headers=list(request.headers.items()),
content=body,
)
httpx_response = self._handle(httpx_request)
headers = {
key: value
for key, value in httpx_response.headers.items()
if key.lower() not in {"content-length", "transfer-encoding"}
}
return StarletteResponse(
content=httpx_response.content,
status_code=httpx_response.status_code,
headers=headers,
)
return Starlette(
routes=[Route("/{path:path}", endpoint, methods=["GET", "POST"])],
)
def _issue_code(self, challenge: str, nonce: str, groups: tuple[str, ...]) -> str:
code = f"fake-code-{uuid4().hex}"
self._codes[code] = (challenge, nonce, groups)
return code
def authorize(self, authorization_url: str, *, now: datetime | None = None) -> str:
query = parse_qs(urlparse(authorization_url).query)
state = query.get("state", [""])[0]
challenge = query.get("code_challenge", [""])[0]
if not state or not challenge:
raise ValueError("invalid fake authorization request")
return self._issue_code(challenge, query.get("nonce", [""])[0], self.user_groups)
def issue_machine_token(
self,
subject: str,
*,
now: datetime | None = None,
issuer: str | None = None,
audience: str | None = None,
) -> str:
reference = now or datetime.now(UTC)
return self._encode(
{
"sub": subject,
"client_id": subject,
"iss": issuer or self.issuer,
"aud": audience or self.audience,
"iat": int(reference.timestamp()),
"exp": int((reference + timedelta(minutes=5)).timestamp()),
}
)
def _encode(self, claims: dict[str, Any]) -> str:
return jwt.encode(
claims, self._key.private_key, algorithm="RS256", headers={"kid": self._key.kid}
)
def _public_jwk(self) -> dict[str, Any]:
raw = cast(
dict[str, Any],
json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(self._key.private_key.public_key())),
)
return raw | {
"kid": self._key.kid,
"use": "sig",
"alg": "RS256",
}
def _handle(self, request: httpx.Request) -> httpx.Response:
path = urlparse(str(request.url)).path
base_path = urlparse(self._base_url).path.rstrip("/")
if request.method == "GET" and path == f"{base_path}/.well-known/openid-configuration":
return httpx.Response(
200,
json={
"issuer": self.issuer,
"authorization_endpoint": f"{self._base_url}/authorize",
"token_endpoint": f"{self._base_url}/token",
"jwks_uri": f"{self._base_url}/jwks",
"id_token_signing_alg_values_supported": ["RS256"],
},
request=request,
)
if request.method == "GET" and path == f"{base_path}/jwks":
return httpx.Response(200, json={"keys": [self._public_jwk()]}, request=request)
if request.method == "GET" and path == f"{base_path}/authorize":
query = parse_qs(urlparse(str(request.url)).query)
redirect_uri = query.get("redirect_uri", [""])[0]
state = query.get("state", [""])[0]
challenge = query.get("code_challenge", [""])[0]
nonce = query.get("nonce", [""])[0]
persona = query.get("persona", [""])[0]
if not redirect_uri or not state or not challenge:
return httpx.Response(
400, text="invalid fake authorization request", request=request
)
groups = self._persona_groups.get(persona, self.user_groups)
code = self._issue_code(challenge, nonce, groups)
separator = "&" if "?" in redirect_uri else "?"
location = f"{redirect_uri}{separator}code={code}&state={state}"
return httpx.Response(302, headers={"location": location}, request=request)
if request.method == "POST" and path == f"{base_path}/token":
form = parse_qs(request.content.decode("utf-8"))
code = form.get("code", [""])[0]
verifier = form.get("code_verifier", [""])[0]
expected = self._codes.pop(code, None)
if expected is None:
return httpx.Response(400, json={"error": "invalid_grant"}, request=request)
challenge = jwt.utils.base64url_encode(
hashlib.sha256(verifier.encode()).digest()
).decode()
if challenge != expected[0]:
return httpx.Response(400, json={"error": "invalid_grant"}, request=request)
now = datetime.now(UTC)
id_token = self._encode(
{
"sub": self.user_subject,
"iss": self.issuer,
"aud": self.audience,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(seconds=self._token_ttl_seconds)).timestamp()),
"nonce": expected[1],
"groups": list(expected[2]),
}
)
return httpx.Response(
200,
json={
"access_token": id_token,
"id_token": id_token,
"token_type": "Bearer",
"expires_in": self._token_ttl_seconds,
},
request=request,
)
return httpx.Response(404, request=request)
+123
View File
@@ -0,0 +1,123 @@
"""Audience-bound machine principal mapping and bounded revocation cache."""
from __future__ import annotations
import re
import threading
import time
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from uuid import UUID
from .errors import InvalidToken, RevokedMachinePrincipal, UnknownMachinePrincipal
from .models import AuthenticatedPrincipal, ManagementRole, PrincipalKind, ValidatedClaims
from .provider import OIDCProviderClient
@dataclass(frozen=True)
class MachineClientBinding:
subject: str
client_id: UUID
enabled: bool = True
class PrincipalMapper:
"""Maps trusted identity claims to roles, never to project/environment bindings."""
def __init__(
self,
*,
role_group_map: Mapping[str, ManagementRole] | None = None,
machine_subjects: Mapping[str, MachineClientBinding] | None = None,
) -> None:
self.role_group_map = dict(role_group_map or {})
self.machine_subjects = dict(machine_subjects or {})
def map_claims(self, claims: ValidatedClaims) -> AuthenticatedPrincipal:
machine = self.machine_subjects.get(claims.subject)
if machine is not None:
if not machine.enabled:
raise UnknownMachinePrincipal()
return AuthenticatedPrincipal(
subject=claims.subject,
issuer=claims.issuer,
audience=claims.audience,
kind=PrincipalKind.MACHINE_CLIENT,
client_id=machine.client_id,
machine_subject=claims.subject,
expires_at=claims.expires_at,
issued_at=claims.issued_at,
)
roles = frozenset(
role
for group in claims.groups
for role in [self.role_group_map.get(group)]
if role is not None
)
return AuthenticatedPrincipal(
subject=claims.subject,
issuer=claims.issuer,
audience=claims.audience,
kind=PrincipalKind.MANAGEMENT_USER,
groups=claims.groups,
roles=roles,
expires_at=claims.expires_at,
issued_at=claims.issued_at,
)
class RevocationCache:
"""Caches revocation truth for at most 60 seconds; invalidation is immediate."""
def __init__(self, lookup: Callable[[UUID], bool], *, ttl_seconds: int = 60) -> None:
self.lookup = lookup
self.ttl_seconds = max(1, min(ttl_seconds, 60))
self._values: dict[UUID, tuple[bool, float]] = {}
self._lock = threading.RLock()
def is_revoked(self, client_id: UUID) -> bool:
now = time.monotonic()
with self._lock:
cached = self._values.get(client_id)
if cached is not None and cached[1] > now:
return cached[0]
value = bool(self.lookup(client_id))
with self._lock:
self._values[client_id] = (value, now + self.ttl_seconds)
return value
def invalidate(self, client_id: UUID) -> None:
with self._lock:
self._values.pop(client_id, None)
class MachinePrincipalAuthenticator:
"""Validates a Hub-audience bearer and enforces client registration/revocation."""
_bearer = re.compile(r"^Bearer ([A-Za-z0-9._~+/=-]{1,65536})$")
def __init__(
self,
provider: OIDCProviderClient,
mapper: PrincipalMapper,
revocations: RevocationCache,
) -> None:
self.provider = provider
self.mapper = mapper
self.revocations = revocations
def authenticate(
self,
authorization_header: str | None,
) -> AuthenticatedPrincipal:
if not authorization_header:
raise InvalidToken()
match = self._bearer.fullmatch(authorization_header.strip())
if match is None:
raise InvalidToken()
principal = self.mapper.map_claims(self.provider.validate_token(match.group(1)))
if principal.kind is not PrincipalKind.MACHINE_CLIENT or principal.client_id is None:
raise InvalidToken()
if self.revocations.is_revoked(principal.client_id):
raise RevokedMachinePrincipal()
return principal
+126
View File
@@ -0,0 +1,126 @@
"""Typed identity models with no raw token or project-binding claims."""
from __future__ import annotations
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ManagementRole(StrEnum):
ADMIN = "admin"
OPERATOR = "operator"
AUDITOR = "auditor"
VIEWER = "viewer"
class PrincipalKind(StrEnum):
MANAGEMENT_USER = "management_user"
MACHINE_CLIENT = "machine_client"
class ValidatedClaims(BaseModel):
"""The small validated projection used by authorization mapping."""
model_config = ConfigDict(extra="ignore", frozen=True)
subject: str = Field(min_length=1, max_length=512, alias="sub")
issuer: str = Field(min_length=1, max_length=2048, alias="iss")
audience: tuple[str, ...] = Field(alias="aud")
expires_at: datetime = Field(alias="exp")
issued_at: datetime = Field(alias="iat")
not_before: datetime | None = Field(default=None, alias="nbf")
client_subject: str | None = Field(default=None, alias="azp")
client_id: str | None = None
nonce: str | None = None
groups: tuple[str, ...] = ()
roles: tuple[str, ...] = ()
@field_validator("expires_at", "issued_at", "not_before", mode="before")
@classmethod
def timezone_aware(cls, value: Any) -> Any:
if isinstance(value, int | float):
return datetime.fromtimestamp(value, tz=UTC)
if isinstance(value, datetime) and value.tzinfo is None:
raise ValueError("JWT timestamps must be timezone-aware")
return value
@field_validator("audience", mode="before")
@classmethod
def normalize_audience(cls, value: Any) -> tuple[str, ...]:
if isinstance(value, str):
return (value,)
if isinstance(value, list | tuple) and all(isinstance(item, str) for item in value):
return tuple(value)
raise ValueError("invalid JWT audience")
@field_validator("groups", "roles", mode="before")
@classmethod
def normalize_strings(cls, value: Any) -> tuple[str, ...]:
if value is None:
return ()
if isinstance(value, str):
return (value,)
if isinstance(value, list | tuple) and all(isinstance(item, str) for item in value):
return tuple(value)
raise ValueError("invalid identity group or role claim")
class AuthenticatedPrincipal(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
subject: str = Field(min_length=1, max_length=512)
issuer: str = Field(min_length=1, max_length=2048)
audience: tuple[str, ...]
kind: PrincipalKind
client_id: UUID | None = None
machine_subject: str | None = None
groups: tuple[str, ...] = ()
roles: frozenset[ManagementRole] = frozenset()
expires_at: datetime
issued_at: datetime
@field_validator("expires_at", "issued_at")
@classmethod
def require_aware_time(cls, value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("principal timestamps must be timezone-aware")
return value
class SessionMetadata(BaseModel):
"""Safe browser-visible session data; never contains bearer tokens."""
model_config = ConfigDict(extra="forbid", frozen=True)
subject: str
client_id: UUID | None
kind: PrincipalKind
roles: frozenset[ManagementRole]
expires_at: datetime
class TokenSet(BaseModel):
"""Internal token-exchange result. Never serialize this model to a browser."""
model_config = ConfigDict(extra="ignore")
access_token: str = Field(min_length=1, max_length=65536)
token_type: Literal["Bearer", "bearer"] = "Bearer" # noqa: S105
expires_in: int | None = Field(default=None, ge=1, le=86400)
refresh_token: str | None = Field(default=None, max_length=65536)
id_token: str | None = Field(default=None, max_length=65536)
class SessionCookie(BaseModel):
"""Internal result used to set an opaque session cookie and CSRF token."""
model_config = ConfigDict(extra="forbid", frozen=True)
value: str
csrf_token: str
metadata: SessionMetadata
set_cookie: str
+219
View File
@@ -0,0 +1,219 @@
"""OIDC discovery, key rotation, and asymmetric JWT validation."""
from __future__ import annotations
import json
import time
from datetime import UTC, datetime, timedelta
from typing import Any
from urllib.parse import urlencode, urljoin
import httpx
import jwt
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, TypeAdapter, field_validator
from .config import OIDCSettings
from .errors import InvalidToken, ProviderUnavailable, TokenExchangeError
from .models import TokenSet, ValidatedClaims
class DiscoveryDocument(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
issuer: str
authorization_endpoint: AnyHttpUrl
token_endpoint: AnyHttpUrl
jwks_uri: AnyHttpUrl
id_token_signing_alg_values_supported: tuple[str, ...] = ("RS256",)
@field_validator("id_token_signing_alg_values_supported", mode="before")
@classmethod
def normalize_algorithms(cls, value: Any) -> tuple[str, ...]:
if value is None:
return ("RS256",)
return tuple(value)
@field_validator("issuer")
@classmethod
def validate_issuer_url(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
class OIDCProviderClient:
"""Provider client with bounded discovery/JWKS caches and one rotation retry."""
def __init__(
self,
settings: OIDCSettings,
*,
transport: httpx.BaseTransport | None = None,
timeout_seconds: float = 5.0,
cache_ttl_seconds: int = 300,
) -> None:
self.settings = settings
self._client = httpx.Client(transport=transport, timeout=timeout_seconds)
self._cache_ttl_seconds = max(30, min(cache_ttl_seconds, 900))
self._discovery: DiscoveryDocument | None = None
self._discovery_loaded = 0.0
self._jwks: dict[str, dict[str, Any]] = {}
self._jwks_loaded = 0.0
def close(self) -> None:
self._client.close()
def _request_json(self, method: str, url: str, **kwargs: Any) -> dict[str, Any]:
try:
response = self._client.request(method, url, **kwargs)
response.raise_for_status()
value = response.json()
except (httpx.HTTPError, ValueError, json.JSONDecodeError) as exc:
raise ProviderUnavailable() from exc
if not isinstance(value, dict):
raise ProviderUnavailable()
return value
def discover(self, *, force: bool = False) -> DiscoveryDocument:
now = time.monotonic()
if (
self._discovery is not None
and not force
and now - self._discovery_loaded < self._cache_ttl_seconds
):
return self._discovery
configured_issuer = str(self.settings.issuer)
discovery_base = configured_issuer.rstrip("/") + "/"
raw = self._request_json("GET", urljoin(discovery_base, ".well-known/openid-configuration"))
try:
document = DiscoveryDocument.model_validate(raw)
except ValueError as exc:
raise ProviderUnavailable() from exc
if document.issuer != configured_issuer:
raise ProviderUnavailable()
issuer_origin = discovery_base.split("/", 3)[:3]
for endpoint in (
document.authorization_endpoint,
document.token_endpoint,
document.jwks_uri,
):
if str(endpoint).split("/", 3)[:3] != issuer_origin:
raise ProviderUnavailable()
self._discovery = document
self._discovery_loaded = now
return document
def _load_jwks(self, *, force: bool = False) -> dict[str, dict[str, Any]]:
now = time.monotonic()
if self._jwks and not force and now - self._jwks_loaded < self._cache_ttl_seconds:
return self._jwks
document = self.discover()
raw = self._request_json("GET", str(document.jwks_uri))
keys = raw.get("keys")
if not isinstance(keys, list):
raise ProviderUnavailable()
parsed: dict[str, dict[str, Any]] = {}
for key in keys:
if (
isinstance(key, dict)
and isinstance(key.get("kid"), str)
and key.get("kty") in {"RSA", "EC", "OKP"}
):
parsed[key["kid"]] = key
if not parsed:
raise ProviderUnavailable()
self._jwks = parsed
self._jwks_loaded = now
return parsed
def build_authorization_url(self, *, state: str, nonce: str, code_challenge: str) -> str:
document = self.discover()
query = urlencode(
{
"response_type": "code",
"client_id": self.settings.client_id,
"redirect_uri": str(self.settings.redirect_uri),
"scope": "openid profile email groups",
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
)
return f"{document.authorization_endpoint}?{query}"
def exchange_code(self, *, code: str, code_verifier: str) -> TokenSet:
document = self.discover()
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": str(self.settings.redirect_uri),
"client_id": self.settings.client_id,
"code_verifier": code_verifier,
}
secret = self.settings.client_secret()
if secret is not None:
data["client_secret"] = secret
try:
response = self._client.post(str(document.token_endpoint), data=data)
response.raise_for_status()
return TokenSet.model_validate(response.json())
except (httpx.HTTPError, ValueError, json.JSONDecodeError) as exc:
raise TokenExchangeError() from exc
def validate_token(self, token: str, *, now: datetime | None = None) -> ValidatedClaims:
if not token or len(token) > 65536:
raise InvalidToken()
try:
header = jwt.get_unverified_header(token)
except jwt.PyJWTError as exc:
raise InvalidToken() from exc
algorithm = header.get("alg")
document = self.discover()
kid = header.get("kid")
if not isinstance(algorithm, str) or algorithm not in self.settings.allowed_algorithms:
raise InvalidToken()
if (
not isinstance(kid, str)
or not kid
or algorithm not in document.id_token_signing_alg_values_supported
):
raise InvalidToken()
keys = self._load_jwks()
key = keys.get(kid)
if key is None:
keys = self._load_jwks(force=True)
key = keys.get(kid)
if key is None:
raise InvalidToken()
try:
signing_key = jwt.PyJWK.from_dict(key).key
claims = jwt.decode(
token,
signing_key,
algorithms=list(self.settings.allowed_algorithms),
audience=self.settings.audience,
issuer=str(document.issuer),
leeway=self.settings.clock_skew_seconds,
options={
"require": ["sub", "iss", "aud", "exp", "iat"],
"verify_exp": False,
"verify_iat": False,
"verify_nbf": False,
},
)
validated = ValidatedClaims.model_validate(claims)
except (jwt.PyJWTError, ValueError, TypeError) as exc:
raise InvalidToken() from exc
reference = now or datetime.now(UTC)
if reference.tzinfo is None or reference.utcoffset() is None:
raise InvalidToken()
skew = timedelta(seconds=self.settings.clock_skew_seconds)
if validated.expires_at + skew <= reference:
raise InvalidToken()
if validated.issued_at - skew > reference:
raise InvalidToken()
if validated.not_before is not None and validated.not_before - skew > reference:
raise InvalidToken()
return validated
+70
View File
@@ -0,0 +1,70 @@
"""FastAPI routes for the same-origin management BFF."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from .bff import BFFService
from .errors import AuthError
def _safe_error(exc: AuthError) -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": exc.code, "message": exc.public_message},
headers={"Cache-Control": "no-store"},
)
def build_auth_router(bff: BFFService) -> APIRouter:
router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/login", include_in_schema=False)
def login() -> RedirectResponse:
try:
return RedirectResponse(
bff.begin_login(), status_code=status.HTTP_307_TEMPORARY_REDIRECT
)
except AuthError as exc:
raise _safe_error(exc) from exc
@router.get("/callback", include_in_schema=False)
def callback(code: str, state: str) -> RedirectResponse:
try:
session = bff.complete_login(code=code, state=state)
except AuthError as exc:
raise _safe_error(exc) from exc
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
response.headers["Set-Cookie"] = session.set_cookie
response.headers["Cache-Control"] = "no-store"
return response
@router.get("/session")
def session(request: Request, response: Response) -> dict[str, object]:
cookie = request.cookies.get(bff.settings.cookie_name)
if cookie is None:
response.headers["Cache-Control"] = "no-store"
return {"authenticated": False}
try:
metadata = bff.session_metadata(cookie)
record = bff.sessions.get(cookie)
except AuthError as exc:
raise _safe_error(exc) from exc
return {
"authenticated": True,
"csrf_token": record.csrf_token,
"session": metadata.model_dump(mode="json"),
}
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def logout(request: Request, response: Response) -> None:
try:
cookie = request.cookies.get(bff.settings.cookie_name)
clear_cookie = bff.logout(cookie, request.headers.get("X-CSRF-Token"))
except AuthError as exc:
raise _safe_error(exc) from exc
response.headers["Set-Cookie"] = clear_cookie
response.headers["Cache-Control"] = "no-store"
return router