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