| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| """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 |
| 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() |
| |
| |
| 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: |
| |
| |
| |
| 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 |
| 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, |
| }, |
| |
| |
| 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 |
|
|