Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
288 lines
14 KiB
Python
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.append_audit({
|
|
"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
|