256 lines
8.6 KiB
Python
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()
|