File size: 3,869 Bytes
d37642c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714a774
d37642c
 
 
 
 
 
 
714a774
 
 
 
 
 
 
 
 
d37642c
 
 
 
 
714a774
 
 
 
 
 
 
 
 
 
 
d37642c
 
 
 
 
 
714a774
d37642c
 
 
714a774
d37642c
 
 
 
714a774
d37642c
 
 
 
714a774
 
 
 
 
 
 
 
 
 
 
 
d37642c
714a774
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d37642c
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
auth.py — Hugging Face token verification.

The frontend signs the user in with "Sign in with Hugging Face" and forwards the
resulting OAuth access token to this server on every scan. We verify that token
against Hugging Face's userinfo endpoint to establish *who* the caller is. The
stable identity is the `sub` field (an immutable user id); usernames can change.

This module is deliberately tiny and dependency-light (just `requests`).
"""

from __future__ import annotations

import requests

USERINFO_URL = "https://huggingface.co/oauth/userinfo"
WHOAMI_URL = "https://huggingface.co/api/whoami-v2"
_TIMEOUT_S = 10


def verify_hf_token(token: str | None) -> dict | None:
    """Return the user info dict for a valid token, else None.

    A valid response contains at least: sub, preferred_username, name, picture.

    Two token kinds are accepted:
      - OAuth access tokens (production frontend "Sign in with HF") -> verified
        via the OIDC /oauth/userinfo endpoint.
      - Personal access tokens (local testing / single-user mode) -> /oauth/userinfo
        rejects them, so we fall back to /api/whoami-v2 and map its response into
        the same shape (sub<-id, preferred_username<-name, name<-fullname,
        picture<-avatarUrl).

    Never raises — all failures collapse to None so callers can treat an invalid
    token identically to a missing one.
    """
    if not token:
        return None

    info = _verify_oauth_userinfo(token)
    if info is None:
        info = _verify_whoami(token)
    if not info or not info.get("sub"):
        return None
    return info


def _verify_oauth_userinfo(token: str) -> dict | None:
    """OIDC userinfo — works for OAuth access tokens."""
    try:
        resp = requests.get(
            USERINFO_URL,
            headers={"Authorization": f"Bearer {token}"},
            timeout=_TIMEOUT_S,
        )
    except requests.RequestException as exc:
        print(f"[auth] userinfo request failed: {exc}")
        return None
    if resp.status_code != 200:
        # 401 for an invalid/expired/non-OAuth token is expected and quiet.
        if resp.status_code != 401:
            print(f"[auth] userinfo returned {resp.status_code}")
        return None
    try:
        return resp.json()
    except ValueError:
        print("[auth] userinfo returned non-JSON body")
        return None


def _verify_whoami(token: str) -> dict | None:
    """Fallback for personal access tokens (and also accepts OAuth tokens).
    Maps the whoami-v2 response into the userinfo shape."""
    try:
        resp = requests.get(
            WHOAMI_URL,
            headers={"Authorization": f"Bearer {token}"},
            timeout=_TIMEOUT_S,
        )
    except requests.RequestException as exc:
        print(f"[auth] whoami request failed: {exc}")
        return None
    if resp.status_code != 200:
        if resp.status_code != 401:
            print(f"[auth] whoami returned {resp.status_code}")
        return None
    try:
        data = resp.json()
    except ValueError:
        print("[auth] whoami returned non-JSON body")
        return None
    if data.get("type") != "user" or not data.get("id"):
        return None
    return {
        "sub": data["id"],
        "preferred_username": data.get("name", ""),
        "name": data.get("fullname") or data.get("name", ""),
        "picture": data.get("avatarUrl", ""),
    }


def extract_bearer(auth_header: str | None) -> str | None:
    """Pull the token out of an 'Authorization: Bearer <token>' header.

    Provided for any future REST-style callers; the Gradio endpoints receive the
    token as a plain parameter and don't need this.
    """
    if not auth_header:
        return None
    parts = auth_header.split(None, 1)
    if len(parts) == 2 and parts[0].lower() == "bearer":
        return parts[1].strip()
    return None