File size: 8,794 Bytes
1f28297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98c4bb7
 
 
 
 
 
 
 
 
 
 
 
 
1f28297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
"""
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")

# huggingface_hub adds these routes when OAuth is attached. Centralised so a
# version change is a one-line fix; the paths are handed to the client via
# /api/me rather than hardcoded there.
OAUTH_LOGIN_PATH = "/oauth/huggingface/login"
OAUTH_LOGOUT_PATH = "/oauth/huggingface/logout"

ANON_COOKIE = "s2s_anon"
_COOKIE_MAX_AGE = 60 * 60 * 24 * 30  # 30 days


# Members of these orgs get unlimited usage (like PRO) out of the box. The
# UNLIMITED_ORGS env adds to this set; it doesn't replace it.
_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:  # pragma: no cover - import guard
    logger.info("huggingface_hub OAuth unavailable (%s); sign-in disabled.", exc)
    _OAUTH_IMPORTABLE = False

# Set by attach(): True once OAuth is actually wired (importable + env present).
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:  # pragma: no cover - defensive
        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)


# Surface what we detect (orgs, tier) in logs and on /api/me when set. Handy for
# verifying org gating on the live Space without guessing.
AUTH_DEBUG = bool(os.environ.get("AUTH_DEBUG"))

# whoami-v2 org lookups are cached for the process lifetime, keyed by token, so
# /api/me + /api/session don't each hit the Hub.
_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 current_access_token(request) -> "str | None":
    """The signed-in user's HF OAuth access token, or None.

    Keep this server-side: it is used to attribute load-balancer session
    requests to the signed-in HF account and must never be returned to the
    browser.
    """
    token = _field(current_oauth(request), "access_token")
    if token is None:
        return None
    return str(token).strip() or None


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:  # pragma: no cover - network/permission dependent
        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

    # Anonymous: key by IP and a signed cookie id, minting the cookie if absent.
    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:
    # The Space runs inside an iframe on huggingface.co, so the cookie lives in a
    # cross-site context — it must be SameSite=None; Secure or the browser drops it.
    response.set_cookie(
        ANON_COOKIE, signed,
        max_age=_COOKIE_MAX_AGE, httponly=True, samesite="none", secure=True,
    )