| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| """Contains a helper to get the token from machine (env variable, secret or config file).""" |
|
|
| import configparser |
| import io |
| import logging |
| import os |
| import time |
| import warnings |
| from pathlib import Path |
| from threading import Lock |
| from typing import TypedDict |
|
|
| from .. import constants |
| from ..errors import DeviceCodeError, OAuthErrorCode, OIDCError |
| from ._fixes import WeakFileLock |
| from ._oauth_device import refresh_access_token |
| from ._runtime import is_colab_enterprise, is_google_colab |
|
|
|
|
| _SECRET_FILE_MODE = 0o600 |
| _SECRET_DIR_MODE = 0o700 |
|
|
|
|
| def _write_secret(path: Path, content: str) -> None: |
| """Write content to file, restricting both the file and its parent directory to owner-only on POSIX systems.""" |
| path.parent.mkdir(parents=True, exist_ok=True, mode=_SECRET_DIR_MODE) |
| fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _SECRET_FILE_MODE) |
| with os.fdopen(fd, "w") as f: |
| f.write(content) |
| try: |
| path.chmod(_SECRET_FILE_MODE) |
| path.parent.chmod(_SECRET_DIR_MODE) |
| except (OSError, NotImplementedError): |
| |
| pass |
|
|
|
|
| _IS_GOOGLE_COLAB_CHECKED = False |
| _GOOGLE_COLAB_SECRET_LOCK = Lock() |
| _GOOGLE_COLAB_SECRET: str | None = None |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| def get_token() -> str | None: |
| """ |
| Get token if user is logged in. |
| |
| Note: in most cases, you should use [`huggingface_hub.utils.build_hf_headers`] instead. This method is only useful |
| if you want to retrieve the token for other purposes than sending an HTTP request. |
| |
| If `HF_OIDC_RESOURCE` is set (Trusted Publishers, typically in CI), a short-lived token obtained via OIDC token |
| exchange takes precedence. Otherwise the token is retrieved from the `HF_TOKEN` environment variable, then from the |
| token file in the Hugging Face home folder. Returns None if user is not logged in. To log in, use [`login`] or |
| `hf auth login`. |
| |
| OAuth tokens obtained with the browser-based login come with a refresh token: when such a token is close to |
| expiry, it is transparently refreshed and persisted before being returned. |
| |
| Note: if `HF_OIDC_RESOURCE` is set but the OIDC token exchange fails, this raises instead of returning `None`, |
| opting into OIDC is explicit, so a failure surfaces as a clear error rather than a silent fallback. |
| |
| Returns: |
| `str` or `None`: The token, `None` if it doesn't exist. |
| """ |
| return ( |
| _get_token_from_oidc() |
| or _get_token_from_environment() |
| or _get_token_from_file_refreshed() |
| or _get_token_from_google_colab() |
| ) |
|
|
|
|
| def _get_token_from_google_colab() -> str | None: |
| """Get token from Google Colab secrets vault using `google.colab.userdata.get(...)`. |
| |
| Token is read from the vault only once per session and then stored in a global variable to avoid re-requesting |
| access to the vault. |
| """ |
| |
| if not is_google_colab() or is_colab_enterprise(): |
| return None |
|
|
| |
| |
| |
| |
| |
| with _GOOGLE_COLAB_SECRET_LOCK: |
| global _GOOGLE_COLAB_SECRET |
| global _IS_GOOGLE_COLAB_CHECKED |
|
|
| if _IS_GOOGLE_COLAB_CHECKED: |
| return _GOOGLE_COLAB_SECRET |
|
|
| try: |
| from google.colab import userdata |
| from google.colab.errors import Error as ColabError |
| except ImportError: |
| return None |
|
|
| try: |
| token = userdata.get("HF_TOKEN") |
| _GOOGLE_COLAB_SECRET = _clean_token(token) |
| except userdata.NotebookAccessError: |
| |
| |
| warnings.warn( |
| "\nAccess to the secret `HF_TOKEN` has not been granted on this notebook." |
| "\nYou will not be requested again." |
| "\nPlease restart the session if you want to be prompted again." |
| ) |
| _GOOGLE_COLAB_SECRET = None |
| except userdata.SecretNotFoundError: |
| |
| |
| |
| logger.info( |
| "The secret `HF_TOKEN` does not exist in your Colab secrets. Run `huggingface_hub.login()` to" |
| " authenticate (recommended but still optional to access public models or datasets)." |
| ) |
| _GOOGLE_COLAB_SECRET = None |
| except ColabError as e: |
| |
| warnings.warn(f"\nError while fetching `HF_TOKEN` secret value from your vault: '{str(e)}'.") |
| _GOOGLE_COLAB_SECRET = None |
|
|
| _IS_GOOGLE_COLAB_CHECKED = True |
| return _GOOGLE_COLAB_SECRET |
|
|
|
|
| def _get_token_from_environment() -> str | None: |
| |
| return _clean_token(os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")) |
|
|
|
|
| def _get_token_from_file() -> str | None: |
| try: |
| return _clean_token(Path(constants.HF_TOKEN_PATH).read_text()) |
| except FileNotFoundError: |
| return None |
|
|
|
|
| class _OidcTokenCache(TypedDict): |
| resource: str |
| token: str |
| expires_at: float |
|
|
|
|
| |
| |
| _OIDC_TOKEN_LOCK = Lock() |
| _OIDC_TOKEN_CACHE: _OidcTokenCache | None = None |
| _OIDC_REFRESH_MARGIN = 300 |
|
|
|
|
| def _get_token_from_oidc() -> str | None: |
| """Get a short-lived OIDC token in CI (Trusted Publishers). |
| |
| Enabled by setting `HF_OIDC_RESOURCE`, which scopes the token to a repo or user. |
| The ID token is read from `HF_OIDC_ID_TOKEN` if available, or minted from a supported CI provider (e.g. GitHub Actions). |
| |
| Returns `None` when OIDC is not enabled. |
| If enabled, any failure is raised explicitly rather than falling back silently. |
| |
| See `huggingface_hub._oidc` and https://huggingface.co/docs/hub/trusted-publishers. |
| """ |
| resource = os.environ.get("HF_OIDC_RESOURCE") |
| if not resource: |
| return None |
|
|
| from .._oidc import detect_provider, oidc_login |
|
|
| global _OIDC_TOKEN_CACHE |
| with _OIDC_TOKEN_LOCK: |
| now = time.monotonic() |
| if ( |
| _OIDC_TOKEN_CACHE is not None |
| and _OIDC_TOKEN_CACHE["resource"] == resource |
| and now < _OIDC_TOKEN_CACHE["expires_at"] |
| ): |
| return _OIDC_TOKEN_CACHE["token"] |
|
|
| |
| subject_token = os.environ.get("HF_OIDC_ID_TOKEN") or None |
| if subject_token is None and detect_provider() is None: |
| raise OIDCError( |
| "HF_OIDC_RESOURCE is set but no OIDC id token is available: not running in a supported " |
| "CI provider (github) and HF_OIDC_ID_TOKEN is not set. Set HF_OIDC_ID_TOKEN to the id " |
| "token minted by your CI provider, or unset HF_OIDC_RESOURCE." |
| ) |
|
|
| result = oidc_login(resource=resource, subject_token=subject_token) |
| token = result["access_token"] |
| expires_in = int(result.get("expires_in", 3600)) |
| |
| |
| |
| margin = 0 if subject_token is not None else _OIDC_REFRESH_MARGIN |
| _OIDC_TOKEN_CACHE = { |
| "resource": resource, |
| "token": token, |
| "expires_at": now + max(expires_in - margin, 0), |
| } |
| return token |
|
|
|
|
| class _OAuthRefreshCache(TypedDict): |
| file_token: str |
| resolved_token: str |
| recheck_at: float |
|
|
|
|
| |
| |
| _OAUTH_REFRESH_LOCK = Lock() |
| _OAUTH_REFRESH_CACHE: _OAuthRefreshCache | None = None |
| _OAUTH_REFRESH_MARGIN = 24 * 3600 |
| _OAUTH_RECHECK_INTERVAL = 300 |
| _OAUTH_REFRESH_WARNED = False |
|
|
|
|
| def _get_token_from_file_refreshed() -> str | None: |
| """Get the token from `HF_TOKEN_PATH`, transparently refreshing it if close to expiry.""" |
| token = _get_token_from_file() |
| if token is None: |
| return None |
| return _refresh_oauth_token_if_needed(token) |
|
|
|
|
| def _refresh_oauth_token_if_needed(token: str) -> str: |
| """Refresh an OAuth access token if it is close to expiry. Best-effort: never raises. |
| |
| OAuth tokens obtained with the browser-based login are stored with a `refresh_token` and an |
| `expires_at` timestamp (see `_save_token`). When the active token is one of them and about to |
| expire, exchange the refresh token for a new access token and persist it. Any other token is |
| returned unchanged. |
| """ |
| global _OAUTH_REFRESH_CACHE |
| with _OAUTH_REFRESH_LOCK: |
| now = time.time() |
| cache = _OAUTH_REFRESH_CACHE |
| if cache is not None and cache["file_token"] == token and now < cache["recheck_at"]: |
| return cache["resolved_token"] |
|
|
| token_name, fields = next( |
| ((name, fields) for name, fields in _read_stored_tokens_full().items() if fields.get("hf_token") == token), |
| (None, {}), |
| ) |
| refresh_token = fields.get("refresh_token") |
| expires_at = _parse_expires_at(fields) |
| if token_name is None or refresh_token is None or expires_at is None: |
| |
| |
| current_file_token = _get_token_from_file() |
| if current_file_token is not None and current_file_token != token: |
| return current_file_token |
| |
| _OAUTH_REFRESH_CACHE = { |
| "file_token": token, |
| "resolved_token": token, |
| "recheck_at": now + _OAUTH_RECHECK_INTERVAL, |
| } |
| return token |
|
|
| if expires_at - _OAUTH_REFRESH_MARGIN > now: |
| _OAUTH_REFRESH_CACHE = { |
| "file_token": token, |
| "resolved_token": token, |
| "recheck_at": expires_at - _OAUTH_REFRESH_MARGIN, |
| } |
| return token |
|
|
| try: |
| |
| |
| with WeakFileLock(constants.HF_STORED_TOKENS_PATH + ".lock", timeout=30): |
| |
| fields = _read_stored_tokens_full().get(token_name, {}) |
| if fields.get("hf_token") != token: |
| |
| new_token = fields.get("hf_token") or token |
| new_expires_at = _parse_expires_at(fields) |
| else: |
| response = refresh_access_token(refresh_token) |
| new_token = response["access_token"] |
| new_expires_at = int(now) + int(response["expires_in"]) if "expires_in" in response else None |
| _save_token( |
| token=new_token, |
| token_name=token_name, |
| |
| refresh_token=response.get("refresh_token") or refresh_token, |
| expires_at=new_expires_at, |
| ) |
| |
| if _get_token_from_file() == token: |
| _write_secret(Path(constants.HF_TOKEN_PATH), new_token) |
| logger.info(f"Access token `{token_name}` has been refreshed.") |
| except Exception as e: |
| if isinstance(e, DeviceCodeError) and e.error_code == OAuthErrorCode.INVALID_GRANT: |
| |
| |
| |
| logger.warning( |
| "Your Hugging Face access token has expired and could not be refreshed " |
| f"(session expired or revoked). Run `hf auth login` to re-authenticate. ({e})" |
| ) |
| recheck_at = float("inf") |
| else: |
| |
| _warn_refresh_failure_once(f"Could not refresh your Hugging Face access token: {e}. Will retry later.") |
| recheck_at = now + _OAUTH_RECHECK_INTERVAL |
| |
| _OAUTH_REFRESH_CACHE = {"file_token": token, "resolved_token": token, "recheck_at": recheck_at} |
| return token |
|
|
| _OAUTH_REFRESH_CACHE = { |
| "file_token": new_token, |
| "resolved_token": new_token, |
| |
| |
| "recheck_at": max( |
| now + _OAUTH_RECHECK_INTERVAL, |
| new_expires_at - _OAUTH_REFRESH_MARGIN if new_expires_at else 0, |
| ), |
| } |
| return new_token |
|
|
|
|
| def _warn_refresh_failure_once(message: str) -> None: |
| global _OAUTH_REFRESH_WARNED |
| if not _OAUTH_REFRESH_WARNED: |
| logger.warning(message) |
| _OAUTH_REFRESH_WARNED = True |
|
|
|
|
| def _parse_expires_at(fields: dict[str, str]) -> int | None: |
| """Parse the `expires_at` field of a stored-tokens section, `None` if missing or corrupt.""" |
| try: |
| return int(fields["expires_at"]) |
| except (KeyError, ValueError): |
| return None |
|
|
|
|
| def get_stored_tokens() -> dict[str, str]: |
| """ |
| Returns the parsed INI file containing the access tokens. |
| The file is located at `HF_STORED_TOKENS_PATH`, defaulting to `~/.cache/huggingface/stored_tokens`. |
| If the file does not exist, an empty dictionary is returned. |
| |
| Returns: `dict[str, str]` |
| Key is the token name and value is the token. |
| """ |
| return {token_name: fields.get("hf_token", "") for token_name, fields in _read_stored_tokens_full().items()} |
|
|
|
|
| def _read_stored_tokens_full() -> dict[str, dict[str, str]]: |
| """Read all sections of the stored tokens INI file, with all their fields. |
| |
| Beside `hf_token`, sections for OAuth tokens also carry `refresh_token` and `expires_at` |
| (unix timestamp), used by [`get_token`] to transparently refresh them. |
| """ |
| tokens_path = Path(constants.HF_STORED_TOKENS_PATH) |
| if not tokens_path.exists(): |
| return {} |
| |
| config = configparser.ConfigParser(interpolation=None) |
| try: |
| config.read(tokens_path) |
| return {token_name: dict(config.items(token_name)) for token_name in config.sections()} |
| except configparser.Error as e: |
| logger.error(f"Error parsing stored tokens file: {e}") |
| return {} |
|
|
|
|
| def _save_stored_tokens_full(stored_tokens: dict[str, dict[str, str]]) -> None: |
| """Write all sections and their fields to the stored tokens INI file.""" |
| config = configparser.ConfigParser(interpolation=None) |
| for token_name in sorted(stored_tokens.keys()): |
| config.add_section(token_name) |
| for key, value in stored_tokens[token_name].items(): |
| config.set(token_name, key, value) |
|
|
| buf = io.StringIO() |
| config.write(buf) |
| _write_secret(Path(constants.HF_STORED_TOKENS_PATH), buf.getvalue()) |
|
|
|
|
| def _get_token_by_name(token_name: str) -> str | None: |
| """ |
| Get the token by name. |
| |
| Args: |
| token_name (`str`): |
| The name of the token to get. |
| |
| Returns: |
| `str` or `None`: The token, `None` if it doesn't exist. |
| |
| """ |
| stored_tokens = get_stored_tokens() |
| if token_name not in stored_tokens: |
| return None |
| return _clean_token(stored_tokens[token_name]) |
|
|
|
|
| def _save_token( |
| token: str, token_name: str, *, refresh_token: str | None = None, expires_at: int | None = None |
| ) -> None: |
| """ |
| Save the given token. |
| |
| If the stored tokens file does not exist, it will be created. |
| Args: |
| token (`str`): |
| The token to save. |
| token_name (`str`): |
| The name of the token. |
| refresh_token (`str`, *optional*): |
| OAuth refresh token used to renew the access token when it expires. |
| expires_at (`int`, *optional*): |
| Unix timestamp at which the access token expires. |
| """ |
| stored_tokens = _read_stored_tokens_full() |
| fields = {"hf_token": token} |
| if refresh_token is not None: |
| fields["refresh_token"] = refresh_token |
| if expires_at is not None: |
| fields["expires_at"] = str(expires_at) |
| |
| stored_tokens[token_name] = fields |
| _save_stored_tokens_full(stored_tokens) |
| logger.info(f"The token `{token_name}` has been saved to {constants.HF_STORED_TOKENS_PATH}") |
|
|
|
|
| def _clean_token(token: str | None) -> str | None: |
| """Clean token by removing trailing and leading spaces and newlines. |
| |
| If token is an empty string, return None. |
| """ |
| if token is None: |
| return None |
| return token.replace("\r", "").replace("\n", "").strip() or None |
|
|