feat: add OIDC browser sessions with live access enforcement
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 2s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
tegwick 2026-09-28 12:17:16 +02:00
parent fdea2f5192
commit a15fe032b0
14 changed files with 807 additions and 31 deletions

View file

@ -179,8 +179,12 @@ class AccessBoundary:
routers. Routes added without catalog admission remain denied.
"""
def __init__(self, app, *, host, controller: AccessController | None,
catalog: dict[str, str] | None = None):
catalog: dict[str, str] | None = None, body_timeout: float = 10, browser=None):
if not 0 < body_timeout <= 10:
raise ValueError("body timeout must be positive and at most ten seconds")
self.app, self.host, self.controller = app, host, controller
self.body_timeout = body_timeout
self.browser = browser
self.catalog = catalog if catalog is not None else json.loads(
files("hub_core.security").joinpath("routes.json").read_text()
)["routes"]
@ -196,15 +200,31 @@ class AccessBoundary:
if scope["method"] == "GET" and scope["path"] == "/healthz":
await JSONResponse({"status": "ok"})(scope, receive, send)
return
if self.browser is not None:
from hub_core.security.browser import BROWSER_ROUTES
if (scope["method"], scope["path"]) in BROWSER_ROUTES:
response = await self.browser.handle(Request(scope, receive))
await response(scope, receive, send)
return
correlation = str(uuid4())
context = None
cookie_auth = False
action = None
digest = None
try:
headers = Request(scope).headers.getlist("authorization")
if len(headers) != 1 or not headers[0].startswith("Bearer "):
raise AccessFailure(401, "bearer_required")
token = headers[0][7:]
request = Request(scope, receive)
headers = request.headers.getlist("authorization")
if self.browser is not None and not headers:
token = self.browser.access_token(request)
cookie_auth = True
else:
if self.browser is not None:
from hub_core.security.browser import SESSION_COOKIE
if SESSION_COOKIE in request.cookies:
raise AccessFailure(400, "mixed_browser_credentials")
if len(headers) != 1 or not headers[0].startswith("Bearer "):
raise AccessFailure(401, "bearer_required")
token = headers[0][7:]
if not token or len(token) > 16384 or any(c.isspace() for c in token):
raise AccessFailure(401, "invalid_access_token")
if self.controller is None:
@ -218,11 +238,15 @@ class AccessBoundary:
# Bind policy to the exact request without exposing content to PDP/audit.
request = Request(scope, receive)
chunks, size = [], 0
async for chunk in request.stream():
size += len(chunk)
if size > 1024 * 1024:
raise AccessFailure(413, "request_too_large")
chunks.append(chunk)
try:
async with asyncio.timeout(self.body_timeout):
async for chunk in request.stream():
size += len(chunk)
if size > 1024 * 1024:
raise AccessFailure(413, "request_too_large")
chunks.append(chunk)
except TimeoutError as exc:
raise AccessFailure(408, "request_body_timeout") from exc
body = b"".join(chunks)
digest = hashlib.sha256(b"\0".join([
scope["method"].encode(), scope["path"].encode(),
@ -241,6 +265,8 @@ class AccessBoundary:
for field in ("from_address", "from_agent", "author"):
if field in payload and payload[field] not in context.facts.producer_addresses:
raise AccessFailure(403, "producer_identity_mismatch")
if cookie_auth:
self.browser.access_token(request) # Recheck expiry/logout after owner awaits.
scope.setdefault("state", {})["hub_access"] = context
except Exception as exc:
failure = exc if isinstance(exc, AccessFailure) else AccessFailure(503, "access_unavailable")