tracebin / auth.py
lvwerra's picture
lvwerra HF Staff
oauth-restricted traces, download + agent-prompt buttons
6b41207 verified
Raw History Blame Contribute Delete
6.66 kB
"""Sign-in with Hugging Face, and the signed session cookie behind it.
Enabled by ``hf_oauth: true`` in the Space's README metadata, which injects
``OAUTH_CLIENT_ID`` / ``OAUTH_CLIENT_SECRET`` / ``OPENID_PROVIDER_URL``. Only
``openid profile`` is requested — the app needs a username and nothing else. It
never asks for repo scopes, so signing in grants it no access to your account.
Sessions are a signed cookie rather than server state: a Space can restart at
any time and there is nowhere durable to keep sessions that isn't the traces
bucket.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import os
import secrets
import time
import urllib.parse
import urllib.request
log = logging.getLogger("tracebin.auth")
SESSION_COOKIE = "tb_session"
STATE_COOKIE = "tb_oauth"
SESSION_TTL = int(os.environ.get("SESSION_TTL_SECONDS", 8 * 3600))
# Rotating this on restart just means signing in again — one redirect, usually
# invisible. Set SESSION_SECRET to keep sessions across restarts.
SESSION_SECRET = os.environ.get("SESSION_SECRET") or secrets.token_hex(32)
_provider_cache: dict[str, str] = {}
def oauth_enabled() -> bool:
return bool(os.environ.get("OAUTH_CLIENT_ID") and os.environ.get("OAUTH_CLIENT_SECRET"))
# ---------------------------------------------------------------------------
# signed cookies
# ---------------------------------------------------------------------------
def _b64e(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _b64d(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def sign(payload: dict) -> str:
body = _b64e(json.dumps(payload, separators=(",", ":")).encode())
mac = hmac.new(SESSION_SECRET.encode(), body.encode(), hashlib.sha256).digest()
return f"{body}.{_b64e(mac)}"
def unsign(value: str | None) -> dict | None:
"""Return the payload if the signature and expiry both hold, else None."""
if not value or "." not in value:
return None
body, _, mac = value.partition(".")
expected = hmac.new(SESSION_SECRET.encode(), body.encode(), hashlib.sha256).digest()
try:
if not hmac.compare_digest(_b64d(mac), expected):
return None
payload = json.loads(_b64d(body))
except (ValueError, TypeError):
return None
if not isinstance(payload, dict):
return None
if payload.get("exp", 0) < time.time():
return None
return payload
def session_username(request) -> str | None:
"""The signed-in HF username, or None."""
payload = unsign(request.cookies.get(SESSION_COOKIE))
if not payload:
return None
username = payload.get("u")
return username if isinstance(username, str) and username else None
def make_session(username: str) -> str:
return sign({"u": username, "exp": int(time.time()) + SESSION_TTL})
# ---------------------------------------------------------------------------
# OIDC
# ---------------------------------------------------------------------------
def _provider() -> dict[str, str]:
"""Discover the provider's endpoints once, then reuse them."""
if _provider_cache:
return _provider_cache
base = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co").rstrip("/")
url = f"{base}/.well-known/openid-configuration"
with urllib.request.urlopen(url, timeout=15) as response:
config = json.loads(response.read())
_provider_cache.update(
{
"authorization_endpoint": config["authorization_endpoint"],
"token_endpoint": config["token_endpoint"],
"userinfo_endpoint": config["userinfo_endpoint"],
}
)
return _provider_cache
def authorize_url(redirect_uri: str, state: str) -> str:
query = urllib.parse.urlencode(
{
"client_id": os.environ["OAUTH_CLIENT_ID"],
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "openid profile",
"state": state,
}
)
return f"{_provider()['authorization_endpoint']}?{query}"
def exchange_code(code: str, redirect_uri: str) -> str:
"""Authorization code -> access token."""
client_id = os.environ["OAUTH_CLIENT_ID"]
client_secret = os.environ["OAUTH_CLIENT_SECRET"]
data = urllib.parse.urlencode(
{
"client_id": client_id,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": redirect_uri,
}
).encode()
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
request = urllib.request.Request(
_provider()["token_endpoint"],
data=data,
headers={
"Authorization": f"Basic {basic}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
with urllib.request.urlopen(request, timeout=15) as response:
return json.loads(response.read())["access_token"]
def fetch_username(access_token: str) -> str:
request = urllib.request.Request(
_provider()["userinfo_endpoint"],
headers={"Authorization": f"Bearer {access_token}"},
)
with urllib.request.urlopen(request, timeout=15) as response:
info = json.loads(response.read())
username = info.get("preferred_username") or info.get("name")
if not username:
raise ValueError("userinfo response carried no username")
return username
def new_state() -> str:
return secrets.token_urlsafe(16)
# ---------------------------------------------------------------------------
# allow lists
# ---------------------------------------------------------------------------
def normalize_users(raw) -> list[str]:
"""Accept a list or a comma/whitespace-separated string of HF usernames."""
if raw is None:
return []
if isinstance(raw, str):
parts = raw.replace(",", " ").split()
elif isinstance(raw, (list, tuple)):
parts = [str(p) for p in raw]
else:
return []
seen, out = set(), []
for part in parts:
name = part.strip().lstrip("@").lower()
# HF usernames are alphanumeric with dashes and underscores.
if name and name not in seen and all(c.isalnum() or c in "-_." for c in name):
seen.add(name)
out.append(name)
return out[:100]
def is_allowed(username: str | None, allowed: list[str] | None) -> bool:
if not allowed:
return True # unrestricted: the link is the credential
return bool(username) and username.lower() in {a.lower() for a in allowed}