| """ |
| HF OAuth + per-request identity for the duration limiter. |
| |
| Login uses Hugging Face's native Spaces OAuth via `huggingface_hub` |
| (`attach_huggingface_oauth` / `parse_huggingface_oauth`). The OAuth env |
| (`OAUTH_CLIENT_ID`, ...) is injected by the platform when the Space README sets |
| `hf_oauth: true`, so this only activates on a deployed Space — locally and in |
| direct mode there's no OAuth and the limiter treats everyone as anonymous. |
| |
| Identity: |
| - signed in -> tier 'pro' | 'free', keyed by hashed HF `sub` |
| - anonymous -> tier 'anon', keyed by BOTH hashed client IP and a hashed |
| signed-cookie id (OR-matched in the limiter) |
| """ |
|
|
| import logging |
| import os |
| import secrets |
|
|
| import limiter |
|
|
| logger = logging.getLogger("s2s.auth") |
|
|
| |
| |
| |
| OAUTH_LOGIN_PATH = "/oauth/huggingface/login" |
| OAUTH_LOGOUT_PATH = "/oauth/huggingface/logout" |
|
|
| ANON_COOKIE = "s2s_anon" |
| _COOKIE_MAX_AGE = 60 * 60 * 24 * 30 |
|
|
|
|
| |
| |
| _DEFAULT_UNLIMITED_ORGS = {"cerebras", "huggingfacem4", "smolagents", "pollen-robotics"} |
|
|
|
|
| def _unlimited_orgs() -> "set[str]": |
| """Org usernames whose members get unlimited usage (like PRO). |
| |
| Defaults to {cerebras, HuggingFaceM4, smolagents}; the UNLIMITED_ORGS env |
| (comma/space-separated, e.g. `UNLIMITED_ORGS=my-team`) adds more. Matched |
| case-insensitively against the signed-in user's organisations.""" |
| raw = os.environ.get("UNLIMITED_ORGS", "") |
| extra = {o.strip().lower() for o in raw.replace(",", " ").split() if o.strip()} |
| return _DEFAULT_UNLIMITED_ORGS | extra |
|
|
| try: |
| from huggingface_hub import attach_huggingface_oauth, parse_huggingface_oauth |
| _OAUTH_IMPORTABLE = True |
| except Exception as exc: |
| logger.info("huggingface_hub OAuth unavailable (%s); sign-in disabled.", exc) |
| _OAUTH_IMPORTABLE = False |
|
|
| |
| oauth_enabled = False |
|
|
|
|
| def attach(app) -> bool: |
| """Wire HF OAuth onto the app if it's importable and configured. Returns |
| whether sign-in is available.""" |
| global oauth_enabled |
| if not _OAUTH_IMPORTABLE or not os.environ.get("OAUTH_CLIENT_ID"): |
| return False |
| try: |
| attach_huggingface_oauth(app) |
| oauth_enabled = True |
| logger.info("HF OAuth attached (sign-in enabled).") |
| except Exception as exc: |
| logger.warning("Failed to attach HF OAuth: %r", exc) |
| oauth_enabled = False |
| return oauth_enabled |
|
|
|
|
| def _field(obj, name, default=None): |
| """Read a field whether the user-info is an object or a dict.""" |
| if obj is None: |
| return default |
| if isinstance(obj, dict): |
| return obj.get(name, default) |
| return getattr(obj, name, default) |
|
|
|
|
| |
| |
| AUTH_DEBUG = bool(os.environ.get("AUTH_DEBUG")) |
|
|
| |
| |
| _orgs_cache: "dict[str, set[str]]" = {} |
|
|
|
|
| def current_oauth(request): |
| """The parsed HF OAuth info (user_info + access_token), or None.""" |
| if not oauth_enabled: |
| return None |
| try: |
| return parse_huggingface_oauth(request) |
| except Exception: |
| return None |
|
|
|
|
| def current_user(request): |
| """The signed-in HF user-info, or None.""" |
| return _field(current_oauth(request), "user_info") |
|
|
|
|
| def _user_org_names(user) -> "set[str]": |
| """The user's organisations from the OAuth userinfo, by username/name/id.""" |
| names = set() |
| for org in _field(user, "orgs", []) or []: |
| for key in ("preferred_username", "name", "sub"): |
| val = _field(org, key) |
| if val: |
| names.add(str(val).lower()) |
| return names |
|
|
|
|
| def _orgs_via_token(token: str) -> "set[str]": |
| """Fallback org lookup via the Hub `whoami-v2` API, using the user's OAuth |
| access token. Covers the case where the userinfo claim omits `orgs`.""" |
| if not token: |
| return set() |
| if token in _orgs_cache: |
| return _orgs_cache[token] |
| names: "set[str]" = set() |
| try: |
| import httpx |
|
|
| resp = httpx.get( |
| "https://huggingface.co/api/whoami-v2", |
| headers={"Authorization": f"Bearer {token}"}, |
| timeout=5.0, |
| ) |
| resp.raise_for_status() |
| for org in resp.json().get("orgs", []) or []: |
| for key in ("name", "fullname"): |
| val = org.get(key) |
| if val: |
| names.add(str(val).lower()) |
| except Exception as exc: |
| logger.info("whoami-v2 org lookup failed: %r", exc) |
| _orgs_cache[token] = names |
| return names |
|
|
|
|
| def _org_names(user, token=None, allow=None) -> "set[str]": |
| """The user's org usernames from the OAuth userinfo claim. If that doesn't |
| already satisfy `allow`, fall back to the Hub `whoami-v2` API (the claim is |
| often empty or partial), so membership is resolved either way.""" |
| names = _user_org_names(user) |
| if token and (allow is None or not (allow & names)): |
| names = names | _orgs_via_token(token) |
| return names |
|
|
|
|
| def resolve_tier(user, token=None) -> str: |
| """Tier for a signed-in user: 'pro' (paying), 'org' (allow-listed org |
| member, unlimited), or 'free'. PRO wins over org if both apply.""" |
| if bool(_field(user, "is_pro", False)): |
| return "pro" |
| allow = _unlimited_orgs() |
| names = _org_names(user, token, allow) |
| tier = "org" if (allow & names) else "free" |
| if AUTH_DEBUG: |
| logger.info("tier=%s orgs=%s allow=%s", tier, sorted(names), sorted(allow)) |
| return tier |
|
|
|
|
| def user_view(request) -> dict: |
| """Public profile for /api/me.""" |
| info = current_oauth(request) |
| user = _field(info, "user_info") |
| if not user: |
| return {"loggedIn": False, "tier": "anon"} |
| token = _field(info, "access_token") |
| out = { |
| "loggedIn": True, |
| "username": _field(user, "preferred_username") or _field(user, "name") or "you", |
| "avatar": _field(user, "picture"), |
| "tier": resolve_tier(user, token), |
| } |
| if AUTH_DEBUG: |
| out["orgs"] = sorted(_org_names(user, token)) |
| return out |
|
|
|
|
| def _client_ip(request) -> str: |
| """Real client IP. On HF the app sits behind a proxy, so the user's address |
| is the first hop in X-Forwarded-For, not request.client.host.""" |
| xff = request.headers.get("x-forwarded-for", "") |
| if xff: |
| return xff.split(",")[0].strip() |
| return request.client.host if request.client else "unknown" |
|
|
|
|
| def resolve_identity(request): |
| """Resolve (tier, keys, set_cookie) for this request. |
| |
| `keys` are the limiter usage_daily keys to debit (one for signed-in, two for |
| anonymous). `set_cookie` is a signed value to Set-Cookie when we minted a new |
| anonymous id, else None. |
| """ |
| info = current_oauth(request) |
| user = _field(info, "user_info") |
| if user: |
| sub = _field(user, "sub") or _field(user, "preferred_username") |
| token = _field(info, "access_token") |
| return resolve_tier(user, token), [limiter.hash_key(f"sub:{sub}")], None |
|
|
| |
| ip = _client_ip(request) |
| cookie_id = limiter.verify_cookie(request.cookies.get(ANON_COOKIE, "")) |
| set_cookie = None |
| if not cookie_id: |
| cookie_id = secrets.token_urlsafe(18) |
| set_cookie = limiter.sign_cookie(cookie_id) |
| keys = [limiter.hash_key(f"ip:{ip}"), limiter.hash_key(f"cookie:{cookie_id}")] |
| return "anon", keys, set_cookie |
|
|
|
|
| def set_anon_cookie(response, signed: str) -> None: |
| |
| |
| response.set_cookie( |
| ANON_COOKIE, signed, |
| max_age=_COOKIE_MAX_AGE, httponly=True, samesite="none", secure=True, |
| ) |
|
|