hub-core/hub_core/security/browser.py
tegwick a15fe032b0
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 2s
feat: add OIDC browser sessions with live access enforcement
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
2026-09-28 12:17:16 +02:00

288 lines
14 KiB
Python

"""Confidential OIDC/PKCE browser sessions; bearer tokens never leave the backend."""
from __future__ import annotations
import asyncio
import base64
import hashlib
import hmac
import secrets
import time
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import quote_plus, urlencode, urlsplit
from uuid import uuid4
import httpx
import jwt
from fastapi import APIRouter, Request
from starlette.responses import JSONResponse, RedirectResponse, Response
from hub_core.security.boundary import AccessController, PROFILE
from hub_core.security.identity import AccessFailure, OIDCVerifier, require_https
SESSION_COOKIE = "__Host-hub-session"
LOGIN_COOKIE = "__Host-hub-login"
BROWSER_ROUTES = frozenset({("GET", "/auth/login"), ("GET", "/auth/callback"),
("GET", "/auth/session"), ("POST", "/auth/logout")})
SESSION_ACTION = "hub.browser.session"
@dataclass(frozen=True)
class BrowserSettings:
origin: str
client_id: str
client_secret_file: Path
session_seconds: int = 300
capacity: int = 1024
def __post_init__(self):
require_https(self.origin)
if urlsplit(self.origin).path or not self.client_id:
raise ValueError("exact origin without path and client ID required")
if not isinstance(self.client_secret_file, Path) or not self.client_secret_file.is_absolute():
raise ValueError("absolute OIDC client credential path required")
if not 1 <= self.session_seconds <= 300 or not 1 <= self.capacity <= 10000:
raise ValueError("bounded browser session settings required")
@property
def redirect_uri(self):
return self.origin + "/auth/callback"
@dataclass(frozen=True)
class PendingLogin:
binding: str
nonce: str
verifier: str
created_at: float
token_endpoint: str
@dataclass(frozen=True)
class Session:
access_token: str
csrf: str
expires_at: float
class BrowserSessions:
def __init__(self, *, settings: BrowserSettings, controller: AccessController):
if not isinstance(controller.identity, OIDCVerifier):
raise ValueError("browser sessions require the admitted OIDC verifier")
self.settings, self.controller = settings, controller
self.identity = controller.identity
self.pending: dict[str, PendingLogin] = {}
self.sessions: dict[str, Session] = {}
def clear(self):
self.pending.clear()
self.sessions.clear()
def _prune(self):
now = time.time()
self.pending = {k: v for k, v in self.pending.items() if now - v.created_at < 300}
self.sessions = {k: v for k, v in self.sessions.items() if now < v.expires_at}
def _origin(self, request: Request):
if str(request.base_url).rstrip("/") != self.settings.origin:
raise AccessFailure(403, "browser_origin_mismatch")
def _session(self, request: Request) -> tuple[str, Session]:
self._origin(request)
self._prune()
sid = request.cookies.get(SESSION_COOKIE, "")
session = self.sessions.get(sid)
if session is None:
raise AccessFailure(401, "browser_session_required")
return sid, session
def _csrf(self, request: Request, session: Session):
values = request.headers.getlist("x-hub-csrf")
if (request.headers.getlist("origin") != [self.settings.origin]
or len(values) != 1 or not hmac.compare_digest(values[0], session.csrf)):
raise AccessFailure(403, "csrf_failed")
def access_token(self, request: Request) -> str:
_, session = self._session(request)
if request.method not in {"GET", "HEAD", "OPTIONS"}:
self._csrf(request, session)
return session.access_token
@staticmethod
def _cookie(response: Response, name: str, value: str, lifetime: int):
response.set_cookie(name, value, max_age=lifetime, path="/", secure=True,
httponly=True, samesite="lax")
@staticmethod
def _delete_cookie(response: Response, name: str):
response.delete_cookie(name, path="/", secure=True, httponly=True, samesite="lax")
async def _begin(self, request: Request) -> Response:
self._origin(request)
# No caller-controlled redirect, scope, prompt or authorization endpoint.
if request.query_params:
raise AccessFailure(400, "unsupported_login_parameters")
self._prune()
if len(self.pending) >= self.settings.capacity:
raise AccessFailure(503, "login_capacity_reached")
response = await self.identity.client.get(
self.identity.issuer.rstrip("/") + "/.well-known/openid-configuration",
timeout=3, follow_redirects=False)
response.raise_for_status()
metadata = response.json()
if (metadata["issuer"] != self.identity.issuer
or "S256" not in metadata.get("code_challenge_methods_supported", [])
or "code" not in metadata.get("response_types_supported", [])
or "client_secret_basic" not in metadata.get("token_endpoint_auth_methods_supported", [])):
raise AccessFailure(503, "unsupported_browser_issuer")
for name in ("authorization_endpoint", "token_endpoint"):
require_https(metadata[name])
# Reserve only after discovery, checking capacity again after the await.
if len(self.pending) >= self.settings.capacity:
raise AccessFailure(503, "login_capacity_reached")
state, binding, nonce, verifier = (secrets.token_urlsafe(32) for _ in range(4))
old_binding = request.cookies.get(LOGIN_COOKIE)
if old_binding:
self.pending = {k: v for k, v in self.pending.items() if v.binding != old_binding}
self.pending[state] = PendingLogin(binding, nonce, verifier, time.time(), metadata["token_endpoint"])
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
location = metadata["authorization_endpoint"] + "?" + urlencode({
"response_type": "code", "client_id": self.settings.client_id,
"redirect_uri": self.settings.redirect_uri, "scope": "openid profile",
"state": state, "nonce": nonce, "code_challenge": challenge,
"code_challenge_method": "S256", "max_age": "0", "acr_values": "aal2",
})
result = RedirectResponse(location, status_code=303)
self._cookie(result, LOGIN_COOKIE, binding, 300)
return result
async def _complete(self, request: Request, correlation: str) -> Response:
params = request.query_params
# Uvicorn's response-time access log must not retain the code/query.
# Reverse proxies must independently exclude callback query logging.
request.scope["query_string"] = b""
self._origin(request)
if (any(len(params.getlist(k)) != 1 for k in params)
or set(params) - {"code", "state", "iss", "session_state", "error", "error_description"}):
raise AccessFailure(400, "invalid_login_response")
self._prune()
state = params.get("state", "")
pending = self.pending.get(state)
if (pending is None or not hmac.compare_digest(
request.cookies.get(LOGIN_COOKIE, ""), pending.binding)):
raise AccessFailure(401, "invalid_login_state")
del self.pending[state] # One-time consumption precedes any await.
if params.get("iss", self.identity.issuer) != self.identity.issuer:
raise AccessFailure(401, "invalid_login_issuer")
code = params.get("code")
if params.get("error") or not code or len(code) > 4096:
raise AccessFailure(401, "login_failed")
secret = self.settings.client_secret_file.read_text().strip()
if not secret or not secret.isascii():
raise AccessFailure(503, "browser_client_unavailable")
response = await self.identity.client.post(pending.token_endpoint,
data={"grant_type": "authorization_code", "code": code,
"redirect_uri": self.settings.redirect_uri, "code_verifier": pending.verifier},
auth=httpx.BasicAuth(quote_plus(self.settings.client_id), quote_plus(secret)),
timeout=3, follow_redirects=False)
response.raise_for_status()
tokens = response.json()
if str(tokens.get("token_type", "")).lower() != "bearer":
raise AccessFailure(401, "invalid_login_token")
access, identity_token = tokens["access_token"], tokens["id_token"]
if (not isinstance(access, str) or not isinstance(identity_token, str)
or len(access) > 16384 or len(identity_token) > 16384):
raise AccessFailure(401, "invalid_login_token")
header, key = await self.identity.signing_key(identity_token)
if header.get("typ", "JWT") != "JWT":
raise AccessFailure(401, "invalid_identity_token_type")
claims = jwt.decode(identity_token, key, algorithms=["RS256"], issuer=self.identity.issuer,
audience=self.settings.client_id,
options={"require": ["iss", "sub", "aud", "iat", "exp", "nonce", "auth_time"]})
if claims.get("typ", "ID") != "ID":
raise AccessFailure(401, "invalid_identity_token_type")
if (not isinstance(claims["nonce"], str) or not hmac.compare_digest(claims["nonce"], pending.nonce)
or (isinstance(claims["aud"], list) and len(claims["aud"]) > 1 and "azp" not in claims)
or claims.get("azp", self.settings.client_id) != self.settings.client_id):
raise AccessFailure(401, "invalid_login_identity")
now = time.time()
if (any(type(claims[k]) is not int for k in ("iat", "exp", "auth_time"))
or not pending.created_at - 1 <= claims["auth_time"] <= now
or not 0 <= now - claims["iat"] <= 300):
raise AccessFailure(401, "stale_login_identity")
if "at_hash" in claims:
expected = base64.urlsafe_b64encode(hashlib.sha256(access.encode()).digest()[:16]).decode().rstrip("=")
if not isinstance(claims["at_hash"], str) or not hmac.compare_digest(claims["at_hash"], expected):
raise AccessFailure(401, "invalid_access_binding")
context = await self.controller.authorize(access, SESSION_ACTION, "/auth/session", correlation,
hashlib.sha256(b"browser-login").hexdigest())
if context.actor.principal_type != "human" or context.actor.subject != claims["sub"]:
raise AccessFailure(403, "invalid_browser_principal")
self._prune()
if len(self.sessions) >= self.settings.capacity:
raise AccessFailure(503, "session_capacity_reached")
old_session = request.cookies.get(SESSION_COOKIE, "")
self.sessions.pop(old_session, None)
sid = secrets.token_urlsafe(32)
expiry = min(time.time() + self.settings.session_seconds, context.actor.expires_at, claims["exp"])
self.sessions[sid] = Session(access, secrets.token_urlsafe(32), expiry)
result = RedirectResponse("/docs", status_code=303)
self._cookie(result, SESSION_COOKIE, sid, max(0, int(expiry - time.time())))
self._delete_cookie(result, LOGIN_COOKIE)
return result
async def handle(self, request: Request) -> Response:
correlation = str(uuid4())
try:
async with asyncio.timeout(10):
if request.headers.getlist("authorization"):
raise AccessFailure(400, "browser_flow_requires_cookies")
if request.url.path == "/auth/login":
result = await self._begin(request)
elif request.url.path == "/auth/callback":
result = await self._complete(request, correlation)
else:
sid, session = self._session(request)
if request.method == "POST":
self._csrf(request, session)
del self.sessions[sid] # Local logout works during owner outages.
result = Response(status_code=204)
self._delete_cookie(result, SESSION_COOKIE)
else:
await self.controller.authorize(session.access_token, SESSION_ACTION, "/auth/session",
correlation, hashlib.sha256(b"browser-session").hexdigest())
self._session(request)
result = JSONResponse({"authenticated": True, "csrf_token": session.csrf,
"expires_at": session.expires_at})
except Exception as exc:
failure = exc if isinstance(exc, AccessFailure) else AccessFailure(
401 if isinstance(exc, jwt.PyJWTError) else 503, "browser_login_unavailable")
try:
async with asyncio.timeout(3):
await self.controller.audit.append({
"profile": PROFILE, "correlation_id": correlation,
"outcome": "refused", "reason": failure.code,
"action": SESSION_ACTION, "target_tenant": "tenant:platform",
"resource_digest": hashlib.sha256(request.url.path.encode()).hexdigest(),
})
except Exception:
failure = AccessFailure(503, "audit_unavailable")
result = JSONResponse({"detail": failure.code}, status_code=failure.status)
if request.url.path == "/auth/callback":
self._delete_cookie(result, LOGIN_COOKIE)
result.headers.update({"X-Correlation-ID": correlation, "Cache-Control": "no-store", "Pragma": "no-cache",
"Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff"})
return result
def create_browser_router() -> APIRouter:
router = APIRouter()
@router.get("/auth/login", include_in_schema=False)
@router.get("/auth/callback", include_in_schema=False)
@router.get("/auth/session", include_in_schema=False)
@router.post("/auth/logout", include_in_schema=False)
async def browser_flow(request: Request) -> Response:
return await request.app.state.browser_sessions.handle(request)
return router