File size: 6,655 Bytes
6b41207 | 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 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | """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}
|