Spaces:
Paused
Paused
File size: 6,545 Bytes
17b22d9 e14044c 17b22d9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | """Sign in with Hugging Face, so inference is billed to the viewer.
A Space with ``hf_oauth: true`` in its README header is given
``OAUTH_CLIENT_ID``, ``OAUTH_CLIENT_SECRET``, ``OAUTH_SCOPES`` and
``OPENID_PROVIDER_URL`` in its environment. Gradio implements this flow for
you; a Docker Space has to do it itself, which is this file.
The scope that matters is ``inference-api``: the access token it yields can
call the router, and the *viewer's* account is billed rather than the Space
owner's. Without it a sign-in tells us who someone is and nothing else, which
is the wrong trade for the amount of machinery involved.
The token is held in a signed, http-only session cookie and never leaves the
server. Nothing here is reachable off a Space, because no client id exists.
"""
from __future__ import annotations
import os
import secrets
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
router = APIRouter()
# The paths a Space's OAuth app is allowed to redirect back to are fixed by the
# platform. `/auth/callback` is the one this app registers.
CALLBACK_PATH = "/auth/callback"
def provider() -> str:
return os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co").rstrip("/")
def client_id() -> str | None:
return os.environ.get("OAUTH_CLIENT_ID")
def configured() -> bool:
"""True when the platform gave us an OAuth app to use."""
return bool(client_id() and os.environ.get("OAUTH_CLIENT_SECRET"))
def scopes() -> str:
# `openid profile` identifies the viewer; `inference-api` is what lets us
# spend their quota instead of ours.
return os.environ.get("OAUTH_SCOPES") or "openid profile inference-api"
def _redirect_uri(request: Request) -> str:
space = os.environ.get("SPACE_HOST")
if space:
return f"https://{space}{CALLBACK_PATH}"
return str(request.base_url).rstrip("/") + CALLBACK_PATH
@router.get("/api/auth/login")
async def login(request: Request):
if not configured():
return JSONResponse(
{"error": "Sign-in is only available on the deployed Space."},
status_code=503,
)
# CSRF: the callback must present the state we generated for this session.
state = secrets.token_urlsafe(24)
request.session["oauth_state"] = state
query = urlencode({
"client_id": client_id(),
"redirect_uri": _redirect_uri(request),
"response_type": "code",
"scope": scopes(),
"state": state,
"prompt": "consent",
})
return RedirectResponse(f"{provider()}/oauth/authorize?{query}", status_code=302)
@router.get(CALLBACK_PATH)
async def callback(request: Request, code: str | None = None,
state: str | None = None, error: str | None = None):
if error:
return _close_window(f"Hugging Face returned: {error}")
expected = request.session.pop("oauth_state", None)
if not expected:
# We issued a state and it did not come back, which is a cookie
# problem rather than a forgery: the session is gone. Say which,
# because the two have completely different fixes.
return _close_window(
"The sign-in session was not returned by your browser. This is "
"usually third-party cookies being blocked for the embedded "
"Space \u2014 open the Space in its own tab and try again."
)
# Constant time, and both halves required: a callback whose state we did
# not issue is not ours to trust.
if not state or not secrets.compare_digest(state, expected):
return _close_window("Sign-in state did not match. Please try again.")
if not code:
return _close_window("Hugging Face did not return an authorization code.")
try:
async with httpx.AsyncClient(timeout=20.0) as client:
tok = await client.post(
f"{provider()}/oauth/token",
data={
"client_id": client_id(),
"client_secret": os.environ.get("OAUTH_CLIENT_SECRET"),
"grant_type": "authorization_code",
"code": code,
"redirect_uri": _redirect_uri(request),
},
headers={"Accept": "application/json"},
)
if tok.status_code >= 400:
return _close_window(f"Token exchange failed ({tok.status_code}).")
payload = tok.json()
access = payload.get("access_token")
if not access:
return _close_window("Hugging Face did not return an access token.")
who = await client.get(
f"{provider()}/oauth/userinfo",
headers={"Authorization": f"Bearer {access}"},
)
info = who.json() if who.status_code < 400 else {}
except httpx.HTTPError as e:
return _close_window(f"Could not reach Hugging Face: {type(e).__name__}")
request.session["oauth_token"] = access
request.session["oauth_user"] = info.get("preferred_username") or info.get("name")
request.session["oauth_profile"] = {
"name": info.get("name"),
"username": info.get("preferred_username"),
"picture": info.get("picture"),
# `isPro` and org membership come back on the userinfo for HF.
"isPro": bool(info.get("isPro")),
"orgs": [o.get("preferred_username") or o.get("name")
for o in (info.get("orgs") or []) if isinstance(o, dict)],
"grantedScopes": scopes(),
}
return _close_window(None)
@router.post("/api/auth/logout")
async def logout(request: Request):
for key in ("oauth_token", "oauth_user", "oauth_profile", "oauth_state"):
request.session.pop(key, None)
return {"ok": True}
def _close_window(error: str | None) -> HTMLResponse:
"""Land the browser back on the app.
Sign-in is started in the same tab, so the callback simply navigates home;
an error is carried in the fragment, which never reaches a server log the
way a query string would.
"""
target = "/" if not error else "/#auth-error=" + error.replace('"', "")
return HTMLResponse(
f'<!doctype html><meta charset="utf-8">'
f'<meta http-equiv="refresh" content="0;url={target}">'
f'<body style="background:#1a1c1f;color:#e8e2d6;font:13px ui-monospace,monospace">'
f'<p style="padding:2rem">{error or "Signed in. Returning…"}</p>'
)
|