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,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()
|
||||
Reference in new issue
Block a user