frockemon_generator / security.py
tikatakatoe56-rgb
Allow Gradio startup health check
c7d5c1a
Raw History Blame Contribute Delete
6.27 kB
"""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)]}