anonymous-CAP's picture
Upload folder using huggingface_hub (part 5)
de3bbe0 verified
Raw
History Blame Contribute Delete
7.98 kB
# Copyright 2026 The HuggingFace Team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Device Code OAuth (RFC 8628) for browser-based login, plus access token refresh.
The flow: the client requests a device code, displays a URL and a short user code, the user
authorizes in a browser, and the client polls ``POST {ENDPOINT}/oauth/token`` until a token is
issued. Access tokens may come with a refresh token, used to renew them transparently (see
``utils/_auth.py::get_token``).
This module is self-contained protocol logic: no printing, no persistence. Interactive flows
live in ``_login.py`` (human/library) and ``cli/auth.py`` (machine-readable event stream).
"""
import time
from collections.abc import Callable
from typing import TypedDict, cast
import httpx
from .. import constants
from ..errors import DeviceCodeError, OAuthErrorCode
from ._http import get_session, hf_raise_for_status
_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
_REFRESH_TOKEN_GRANT_TYPE = "refresh_token"
class DeviceCodeInfo(TypedDict):
device_code: str
user_code: str
verification_uri: str
verification_uri_complete: str # falls back to verification_uri if the server omits it
interval: int
expires_in: int
class OAuthTokenResponse(TypedDict, total=False):
access_token: str
refresh_token: str
expires_in: int
token_type: str
def request_device_code() -> DeviceCodeInfo:
"""Request a device code from the Hub's OAuth device authorization endpoint.
The returned dict is normalized: `interval`, `expires_in` and `verification_uri_complete`
are always set (server values, or sensible defaults).
Raises:
[`DeviceCodeError`]: If the request fails.
"""
try:
response = get_session().post(
f"{constants.ENDPOINT}/oauth/device",
data={"client_id": constants.DEVICE_CODE_OAUTH_CLIENT_ID},
timeout=constants.HF_HUB_DOWNLOAD_TIMEOUT,
)
hf_raise_for_status(response)
except httpx.HTTPError as e:
raise DeviceCodeError(f"Failed to request device code from {constants.ENDPOINT}/oauth/device: {e}") from e
info = response.json()
# `interval` is optional per RFC 8628 (5s is the spec-mandated fallback); `expires_in` is
# required but defaulted defensively so polling stays bounded if a server omits it.
info.setdefault("interval", 5)
info.setdefault("expires_in", 900)
if not info.get("verification_uri_complete"):
info["verification_uri_complete"] = info["verification_uri"]
return cast(DeviceCodeInfo, info)
def poll_device_token(
device_info: DeviceCodeInfo, *, on_pending: Callable[[], None] | None = None
) -> OAuthTokenResponse:
"""Poll the token endpoint until the user authorizes the device.
Args:
device_info (`DeviceCodeInfo`):
The device authorization response from [`request_device_code`].
on_pending (`Callable`, *optional*):
Called after each "authorization pending" response (e.g. to print a progress dot).
Returns:
`OAuthTokenResponse`: the full token response: `access_token`, and optionally
`refresh_token` and `expires_in`.
Raises:
[`DeviceCodeError`]: If authorization is denied, the device code expires, or the server
returns an unexpected OAuth error.
"""
interval = device_info["interval"]
deadline = time.monotonic() + device_info["expires_in"]
while time.monotonic() < deadline:
# Inconclusive responses (network blip, 5xx, gateway error page, rate limiting) must not
# abort the login: keep polling until the device code expires (RFC 8628 section 3.5).
# The deadline bounds the total wait even if the endpoint is genuinely broken.
data = None
try:
response = get_session().post(
f"{constants.ENDPOINT}/oauth/token",
data={
"grant_type": _DEVICE_CODE_GRANT_TYPE,
"device_code": device_info["device_code"],
"client_id": constants.DEVICE_CODE_OAUTH_CLIENT_ID,
},
timeout=constants.HF_HUB_DOWNLOAD_TIMEOUT,
)
if response.status_code < 500:
data = response.json()
except (httpx.HTTPError, ValueError):
pass
if data is not None:
if "access_token" in data:
return cast(OAuthTokenResponse, data)
match data.get("error"):
case None:
pass # JSON without an OAuth `error` field (proxy error page, ...): transient
case OAuthErrorCode.AUTHORIZATION_PENDING:
if on_pending is not None:
on_pending()
case OAuthErrorCode.SLOW_DOWN:
interval += 5
case OAuthErrorCode.EXPIRED_TOKEN:
raise DeviceCodeError(
"Device code expired. Please try again.", error_code=OAuthErrorCode.EXPIRED_TOKEN
)
case OAuthErrorCode.ACCESS_DENIED:
raise DeviceCodeError(
"Authorization was denied. Please try again.", error_code=OAuthErrorCode.ACCESS_DENIED
)
case error:
raise DeviceCodeError(
f"OAuth error: {error} - {data.get('error_description', '')}", error_code=error
)
time.sleep(interval)
raise DeviceCodeError("Device code expired (timeout). Please try again.", error_code=OAuthErrorCode.EXPIRED_TOKEN)
def refresh_access_token(refresh_token: str) -> OAuthTokenResponse:
"""Exchange a refresh token for a new access token.
Returns:
`OAuthTokenResponse`: the full token response: `access_token`, and optionally a rotated
`refresh_token` and `expires_in`.
Raises:
[`DeviceCodeError`]: If the server rejects the refresh (`error_code="invalid_grant"` when
the refresh token is expired or revoked) or returns an unexpected response.
"""
try:
response = get_session().post(
f"{constants.ENDPOINT}/oauth/token",
data={
"grant_type": _REFRESH_TOKEN_GRANT_TYPE,
"refresh_token": refresh_token,
"client_id": constants.DEVICE_CODE_OAUTH_CLIENT_ID,
},
# An explicit timeout is critical here: this runs inside `get_token()`, so a hung
# request would otherwise block every Hub call in the process.
timeout=constants.HF_HUB_DOWNLOAD_TIMEOUT,
)
except httpx.HTTPError as e:
raise DeviceCodeError(f"Failed to refresh access token: {e}") from e
data = _parse_token_response(response)
if "access_token" in data:
return cast(OAuthTokenResponse, data)
error = data.get("error")
raise DeviceCodeError(
f"Failed to refresh access token: {error or response.status_code} - {data.get('error_description', '')}",
error_code=error,
)
def _parse_token_response(response: httpx.Response) -> dict:
try:
return response.json()
except ValueError as e:
raise DeviceCodeError(
f"Failed to parse response from {constants.ENDPOINT}/oauth/token "
f"(status {response.status_code}): {response.text[:500]}"
) from e