feat: add OIDC browser sessions with live access enforcement
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
fdea2f5192
commit
a15fe032b0
14 changed files with 807 additions and 31 deletions
|
|
@ -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")
|
||||
|
|
|
|||
288
hub_core/security/browser.py
Normal file
288
hub_core/security/browser.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
"""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
|
||||
|
|
@ -94,19 +94,22 @@ class OIDCVerifier:
|
|||
except (httpx.HTTPError, ValueError, KeyError, TypeError, jwt.PyJWTError) as exc:
|
||||
raise AccessFailure(503, "identity_unavailable") from exc
|
||||
|
||||
async def signing_key(self, token: str):
|
||||
header = jwt.get_unverified_header(token)
|
||||
if header.get("alg") != "RS256" or not isinstance(header.get("kid"), str):
|
||||
raise ValueError("unsupported token")
|
||||
async with self._lock:
|
||||
age = time.monotonic() - self._loaded
|
||||
if age >= self.key_ttl or (header["kid"] not in self._keys and age >= 1):
|
||||
await self._refresh()
|
||||
key = self._keys.get(header["kid"])
|
||||
if key is None:
|
||||
raise ValueError("unknown key")
|
||||
return header, key
|
||||
|
||||
async def authenticate(self, token: str) -> Actor:
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
if header.get("alg") != "RS256" or not isinstance(header.get("kid"), str):
|
||||
raise ValueError("unsupported token")
|
||||
async with self._lock:
|
||||
# At most one unknown-key refresh per second, to bound random-kid traffic.
|
||||
age = time.monotonic() - self._loaded
|
||||
if age >= self.key_ttl or (header["kid"] not in self._keys and age >= 1):
|
||||
await self._refresh()
|
||||
key = self._keys.get(header["kid"])
|
||||
if key is None:
|
||||
raise ValueError("unknown key")
|
||||
header, key = await self.signing_key(token)
|
||||
claims = jwt.decode(token, key, algorithms=["RS256"], issuer=self.issuer,
|
||||
audience=self.audience, leeway=0,
|
||||
options={"require": ["iss", "sub", "aud", "exp", "iat",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue