iscc-app / sinetti_api /browser_session.py
Couscouskabyle's picture
Deploy Sinetti V2 (FastAPI + React) [publication:de66c457b10849fc9d3e7672a9138ceb]
b196778 verified
Raw History Blame
5.87 kB
"""Browser transport only; session_service remains the registered-session authority."""
from __future__ import annotations
import hashlib
import hmac
import os
import time
from urllib.parse import urlsplit
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from sinetti_services import session_token
from sinetti_api.runtime_security import local_loopback_request
_SAFE = frozenset({"GET", "HEAD", "OPTIONS"})
_PREAUTH = frozenset({
"/api/auth/login", "/api/auth/email-code/verify", "/api/auth/email-code/resend",
"/api/auth/oidc/complete", "/api/auth/password-reset/request",
"/api/auth/password-reset/confirm", "/api/auth/browser-session",
})
def secure_cookies(request: Request) -> bool:
return request.url.scheme != "http" or not local_loopback_request(request)
def cookie_names(request: Request) -> tuple[str, str]:
prefix = "__Host-" if secure_cookies(request) else ""
return prefix + "sinetti_session", prefix + "sinetti_csrf"
def request_token(request: Request, authorization: str | None = None) -> str:
# Header presence, including an empty/malformed header, has absolute precedence.
if "authorization" in request.headers or authorization is not None:
value = request.headers.get("authorization", "") if authorization is None else authorization
return value[7:].strip() if value.lower().startswith("bearer ") else ""
return request.cookies.get(cookie_names(request)[0], "")
def _csrf(token: str) -> str:
secret = os.environ.get("SINETTI_SESSION_SECRET", "")
if not secret:
raise RuntimeError("Sinetti session secret is not configured")
return hmac.new(secret.encode(), b"browser-csrf-v1\0" + token.encode(), hashlib.sha256).hexdigest()
def set_session_cookies(response: Response, request: Request, token: str, *, now: float | None = None) -> None:
checked_at = time.time() if now is None else now
claims = session_token.verify(token, now=checked_at)
if claims is None:
raise RuntimeError("Cannot install an invalid session")
remaining = max(0, int(claims["exp"] - checked_at))
session_name, csrf_name = cookie_names(request)
options = dict(max_age=remaining, path="/", secure=secure_cookies(request), samesite="lax")
response.set_cookie(session_name, token, httponly=True, **options)
response.set_cookie(csrf_name, _csrf(token), httponly=False, **options)
response.headers["Cache-Control"] = "no-store"
def clear_session_cookies(response: Response, request: Request) -> None:
for name in cookie_names(request):
response.delete_cookie(name, path="/", secure=secure_cookies(request), httponly=name.endswith("session"), samesite="lax")
response.headers["Cache-Control"] = "no-store"
def _origin(value: str) -> tuple[str, str, int] | None:
try:
parsed = urlsplit(value)
if parsed.scheme not in {"https", "http"} or not parsed.hostname or parsed.username or parsed.password:
return None
if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
return None
return parsed.scheme, parsed.hostname.lower(), parsed.port or (443 if parsed.scheme == "https" else 80)
except ValueError:
return None
def _foreign_origin(request: Request) -> bool:
fetch_site = request.headers.get("sec-fetch-site", "").lower()
if fetch_site not in {"", "same-origin", "none"}:
return True
supplied = request.headers.get("origin")
if supplied is None:
return False # CLI clients have no Origin; cookie writes still require signed CSRF.
actual = _origin(supplied)
configured = os.environ.get("SINETTI_PUBLIC_BASE_URL", "").strip().rstrip("/")
if not secure_cookies(request):
# A real loopback desk may inherit the production URL used in outbound mail.
configured = ""
expected = _origin(configured) if configured else _origin(str(request.base_url).rstrip("/"))
if configured and secure_cookies(request) and expected and expected[0] != "https":
return True
if not configured and secure_cookies(request) and expected:
# HF terminates TLS outside ASGI. Only the same hostname on HTTPS is accepted.
expected = ("https", expected[1], 443 if expected[2] == 80 else expected[2])
return actual is None or expected is None or actual != expected
class BrowserSessionMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] == "http" and scope["method"].upper() not in _SAFE and scope["path"].startswith("/api/"):
request = Request(scope)
cookie_token = request.cookies.get(cookie_names(request)[0], "")
cookie_auth = bool(cookie_token) and "authorization" not in request.headers
reason = None
if (cookie_auth or scope["path"].startswith("/api/auth/")) and _foreign_origin(request):
reason = "Request origin is not allowed"
elif cookie_auth and scope["path"] not in _PREAUTH:
supplied = request.headers.get("x-sinetti-csrf", "")
try:
valid = bool(supplied) and hmac.compare_digest(supplied, _csrf(cookie_token))
except (RuntimeError, ValueError, TypeError):
valid = False
if not valid:
reason = "Session request verification failed"
if reason:
response = JSONResponse({"ok": False, "error": {"code": "csrf_rejected", "message": reason}}, status_code=403)
await response(scope, receive, send)
return
await self.app(scope, receive, send)