"""Mount Gradio with HTTP protection inside its existing signed OAuth session. Coordinator integration (after demo.queue(...)):: from fastapi import FastAPI import security import uvicorn app = security.mount_private_gradio( FastAPI(), demo, path="/", allowed_paths=[ui.STAGING], theme=theme_mod.build(), css=ui.stylesheet()) uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860"))) Use this instead of demo.launch(). The public root/config must contain only an anonymous-safe shell; private data must be loaded by authenticated events. HF checks Frockemasters membership at login (validated README metadata). No live membership lookup occurs: removal does not invalidate issued cookies or cancel already-running jobs/streams. Cookie replay remains possible until OAuth expiry; logout only clears the browser's cookie. Rotate the OAuth secret to invalidate all sessions. This module must ship alongside auth.py/README.md. """ import math import os import gradio.oauth as oauth import time import gradio as gr from starlette.middleware import Middleware from starlette.middleware.sessions import SessionMiddleware from starlette.responses import JSONResponse from starlette.routing import Mount from urllib.parse import parse_qs try: from . import auth except ImportError: import auth def session_username(session): """Read only a session verified by Gradio's SessionMiddleware.""" if not isinstance(session, dict): return None info = session.get("oauth_info") if not isinstance(info, dict): return None expiry = info.get("expires_at") if (type(expiry) not in (int, float) or not math.isfinite(expiry) or expiry <= time.time()): return None profile = info.get("userinfo") if not isinstance(profile, dict): return None name = profile.get("preferred_username") return name if isinstance(name, str) and name.strip() else None class PrivateGradioMiddleware: """Default deny new routes; exempt only the anonymous shell and assets.""" PUBLIC = frozenset({"/", "/config", "/config/", "/theme.css", "/favicon.ico", "/robots.txt", "/manifest.json", "/login/huggingface", "/login/callback", "/logout", # Gradio calls this from localhost before launch() can # report readiness. It triggers component startup only; # it does not expose queue jobs, files or user data. "/gradio_api/startup-events"}) ASSETS = ("/assets/", "/static/", "/svelte/") def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] not in ("http", "websocket"): return await self.app(scope, receive, send) path = scope["path"] root = scope.get("root_path", "") if root and path.startswith(root + "/"): path = path[len(root):] public = (scope["type"] == "http" and scope["method"] in ("GET", "HEAD") and (path in self.PUBLIC or path.startswith(self.ASSETS))) # Deep links restore saved component state into otherwise-public config. if "deep_link" in parse_qs(scope.get("query_string", b"").decode("latin1"), keep_blank_values=True): public = False if "\\" in path or any(part in (".", "..") for part in path.split("/")): public = False if not public and not session_username(scope.get("session")): if scope["type"] == "websocket": await send({"type": "websocket.close", "code": 4401}) return return await JSONResponse({"detail": auth.MESSAGE}, status_code=401, headers={"Cache-Control": "no-store"})(scope, receive, send) async def private_send(message): if message["type"] == "http.response.start" and (not public or path in {"/", "/config", "/config/"}): message["headers"] = [(k, v) for k, v in message.get("headers", []) if k.lower() != b"cache-control"] message["headers"].append((b"cache-control", b"private, no-store")) await send(message) await self.app(scope, receive, private_send) def mount_private_gradio(app, demo, *, path="/", **kwargs): """Mount before serving; never add a second session decoder/signing key.""" gate = auth.for_environment() if gate.required: if any(getattr(oauth, key) != os.environ[key] for key in auth.OAUTH_VARIABLES): raise RuntimeError("OAuth environment changed after Gradio import; restart required") if not demo.expects_oauth: raise RuntimeError("Private Gradio requires a gr.LoginButton") if "app_kwargs" in kwargs or "auth" in kwargs or "auth_dependency" in kwargs: raise ValueError("Authentication/app middleware is owned by security helper") kwargs["app_kwargs"] = {"middleware": [Middleware(PrivateGradioMiddleware)]} result = gr.mount_gradio_app(app, demo, path=path, **kwargs) if gate.required: child = next(route.app for route in reversed(result.routes) if isinstance(route, Mount) and route.path == path.rstrip("/")) classes = [entry.cls for entry in child.user_middleware] if (SessionMiddleware not in classes or classes.index(SessionMiddleware) > classes.index(PrivateGradioMiddleware)): raise RuntimeError("Gradio session middleware must wrap HTTP protection") return result def private_app_kwargs(demo): """Middleware configuration for Blocks.launch on the hosted Space. ZeroGPU discovers registered handlers from Blocks.launch, so production must use that path rather than mounting Gradio under a separate Uvicorn app. Gradio still installs its signed SessionMiddleware around this list. """ gate = auth.for_environment() if not gate.required or not demo.expects_oauth: raise RuntimeError("Private Space launch requires configured OAuth") return {"middleware": [Middleware(PrivateGradioMiddleware)]}