💡

死ぬほどわからなくなった無料版HF SpacesのOAuth連携について死ぬほどわかりやすく解説していくぜ

に公開

こんにちは、sirochildと申します。
今回は僕が直面した、無料版HF spacesにおいてOAuth連携を入れる時に陥りやすい罠やその導入方法を詳しく説明していきます。
後半ではstreamlitでの実装例なんかも交えて状態変化でログインが阻害されないような書き方を解説していくので、ぜひ最後まで読んでください!

HF Spaces に OAuth を入れるメリット

これは一つしかありません。
ユーザーごとの認証が必要で、それでデータ(mnt/dataにある永続ストレージとかですね)を分離したりユーザーごとに異なる体験を用意している時です!

前提条件(無料プランの制限)

今回は無料版の制約があります。
ざっくりOAuthに関わる無料版の制約は2つで、
1,権限の詳細な設定ができなくなる
2,そもそもOAuthアプリを作る形ではなく、Readme.yamlに記載する形でOAuthと連携する
という制約です。
どちらもただログインして認証してもらうのにはあんまり関係がないので、ここが不便だよ~とか外部DBと連携したいけど……みたいな時にざっくり覚えとくくらいでいいと思います。
他にもGPUやストレージ上限などの制約はありますが、ここでは割愛します。

OAuth 認証の仕組み

ユーザーがログインボタンを押すと、ログインページ(正確には権限の認可ページ)に飛んで、そこで認可をすると認可コードからアクセストークンが飛んでそれをチェックしてサーバー側がユーザーデータを出します、みたいな感じです。
ここで大事なのは、セキュリティリスクが高くなりがちな箇所である事という認識です。
アクセストークンはログに出したらダメだし、HTTPS化も必要です。CSRF攻撃への対策もstateでやっちゃいましょう。
というわけで……

実装例

お待ちかねの実装例です。
JSでの実装は自分ではやってないので https://huggingface.co/docs/hub/spaces-oauth を参照するのがオススメ。
今回はpython/streamlitでの実装例を出します。


def get_redirect_uri():
    """
    SpaceのURL形式(直接形式 or パス形式)に応じて正しいリダイレクトURIを生成する
    """
    # SPACE_HOSTが設定されている場合(直接形式URL)はそれを使うのが最も確実
    if SPACE_HOST:
        return f"https://{SPACE_HOST}"
    # SPACE_HOSTがない場合(古い形式のSpaceなど)はSPACE_IDから組み立てる
    elif SPACE_ID:
        return f"https://huggingface.co/spaces/{SPACE_ID}"

ここでは環境変数SPACE_HOSTを値に代入してリダイレクトしています。
このSPACE_HOSTというのは公式の書き方であり、またReadmeに明記した時点で自動で与えられる変数です。なのでこういう書き方で大丈夫です。

def get_hf_token(code: str, redirect_uri: str) -> dict | None:
    """認可コード(code)をアクセストークンに交換する"""
    url = f"{HF_ENDPOINT}/oauth/token"
    payload = {
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": redirect_uri,
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
    }

    try:
        response = requests.post(url, data=payload)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.RequestException as e:
        st.error(f"トークンの取得に失敗しました: {e}")
        # vvv この行が最重要!サーバーからのエラーレスポンスを直接表示 vvv
        if hasattr(e, 'response') and e.response is not None:
            st.error("サーバーからの詳細なエラーレスポンス:")
            st.json(e.response.json())
        return None

こっちはトークンを取得する関数ですね、認可コードをトークンに変換しています。
注意点としては、変数の宣言を間違えたり間違えてログにアクセストークン出したりしないことでしょうか。エラーログは表示してもいいので、気を付けましょう。

def get_user_info(access_token: str) -> dict | None:
    """アクセストークンを使ってユーザー情報を取得する"""
    url = f"{HF_ENDPOINT}/api/whoami-v2"
    headers = {"Authorization": f"Bearer {access_token}"}
    try:
        response = requests.get(url, headers=headers)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.HTTPError as e:
        # HTTPエラーの場合、レスポンス内容を詳しく表示
        st.error(f"ユーザー情報の取得に失敗しました (HTTP Error): {e}")
        st.error(f"ステータスコード: {e.response.status_code}")
        st.error(f"レスポンス: {e.response.text}") # これが最も重要な情報
        return None
    except requests.exceptions.RequestException as e:
        # その他のネットワークエラーなど
        st.error(f"ユーザー情報の取得に失敗しました (Request Exception): {e}")
        return None

ここで重要なのは、絶対にwhoami-v2を呼び出す事。(2025/8/27現在、whoamiの過去バージョンでは動作しませんでした)

f"Bearer ……という書き方も絶対に必要で、これが無いと保護されていない扱いで失敗します(弾かれます)。

では、それらの関数をまとめた実装例(ボタン含め)を一つ、ご紹介します。

            query_params = st.experimental_get_query_params()
            auth_code_list = query_params.get("code",[])

            if auth_code_list:
                auth_code = auth_code_list[0]
                redirect_uri = get_redirect_uri()

                # --- ステップB: アクセストークンの取得 ---
                token_data = get_hf_token(auth_code, redirect_uri)

                if token_data and isinstance(token_data, dict):
                    st.info("ステップ1: アクセストークンの取得に成功しました。") # デバッグ情報
                    access_token = token_data.get('access_token')
                    if access_token:
                        user_data = get_user_info(access_token)

                        if user_data and isinstance(user_data, dict):
                            st.session_state['user_data'] = user_data
                            # 認証直後にuser_idをHF IDで更新し、セッション再初期化
                            st.session_state['user_id'] = user_data.get('id', str(uuid.uuid4()))
                            st.info("ログイン成功!")
                            st.session_state['token_data'] = token_data
                            st.session_state['user_data'] = user_data
                            st.session_state['user_id'] = user_data.get("id")
                            st.experimental_set_query_params()
                            st.rerun()
                        else:
                            st.warning("ステップ3: ユーザー情報の取得に失敗したため、ログインを中断しました。上記のエラーメッセージを確認してください。")
                    else:
                        st.error("エラー: `token_data`内に`access_token`が見つかりませんでした。")
                        st.json(token_data)
            else:
                st.warning("現在ログインしていません。")
                if not CLIENT_ID or not CLIENT_SECRET:
                    st.error("OAuthクライアントが設定されていません。SpaceのREADME.mdを確認し、再起動してください。")
                else:
                    redirect_uri = get_redirect_uri()
                    params = { "client_id": CLIENT_ID, "redirect_uri": redirect_uri, "scope": "openid profile", "state": "STATE_STRING", "response_type": "code", }
                    login_url = f"{HF_ENDPOINT}/oauth/authorize?{urlencode(params)}"
                    st.markdown(f'<a href="{login_url}" target="_self" style="display: inline-block; padding: 10px 20px; background-color: #FFD21E; color: black; text-align: center; text-decoration: none; border-radius: 5px; font-weight: bold;">🤗 Hugging Faceでログイン</a>', unsafe_allow_html=True)

この場合だとアクセストークンや認証コードは表出せず、IDのみをユーザーIDに流用する形になっています。Hugging Faceからのリダイレクト直後かの確認も(URLにcodeがあるか)しっかりと。
ちなみに状態更新後の一貫性を保つためにst.session_state['user_id'] = user_data.get("id")としていますが、もしユーザーIDをIDにしたくない場合はその人に紐ついたIDの生成と管理が必要です。僕はめんどくさいのでやってません。たぶんmnt/data(唯一の永続ディレクトリ)にぶちこんどいてそれをuser_idで呼び出すのがいいと思います。

最後に、これらを有効化するためのReadmeの書き方を紹介します。

hf_oauth: true
hf_oauth_expiration_minutes: 480  # トークンの有効期限(分)
hf_oauth_scopes:
  - read-repos
  - write-repos
  - manage-repos
  - inference-api

これを元々readmeに記載するべきアプリ設定用記述で、上部に書くやつに付け足すだけです。
これ詳細な設定はできないものの一部外す事は可能らしく、

  • read-repos
  • write-repos
  • manage-repos
    とかでも動くっぽいです。まあ全部付けても(悪用しなければ)問題ないと思いますが。

ハマりがちな落とし穴と解決策

はいやってきました、今日のデバッグ地獄タイム。
ここでは3つハマりポイントについての対処を解説します。

1、とにかくログ付けろ

原因がわからなければ対処のしようがないですし、これらの処理は原因が細かい事や環境そのものにある場合も多いです。
とにかくst.info()とかでログ付けて動かすのを習慣にしましょう(ただしセキュリティ的にまずいログは正式に公開するなら消してね)

2、最新の情報を追う(英語公式やフォーラム)

この記事は2025/8/27現在の動作を保証してますが、hugging faceはころころ環境変わるのでフォーラム推奨。日本語だとまず情報出てきません。GPTに聞いても最新の情報には疎いので、おとなしく調べましょう。

3、全部のバージョンを最新にしてみる/安定版にしてみる

バージョン違えば書き方違います、なんてザラなんでバージョン変えてみましょう。(要公式ドキュメント、英語わかんない人は翻訳かけながらでも読む価値あり)
ここらへんは再起動しまくってトライ&エラーする事で原因がわかる事も多いです。手を動かしましょう。

まとめ

・whoami-v2を使え!
・セキュリティに気を付けろ!大事なあなたやユーザーのデータを守ろう!
・手を動かして調べよう!

最後まで読んでくださりありがとうございました。
これで動いてるspacesのアプリ、麻理チャットもぜひ。

https://huggingface.co/spaces/sirochild/mari-chat-3

ほいじゃ。

Discussion