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

256 lines
8.6 KiB
Python

"""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()