"""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