Download hermes_cli/auth_spotify.py from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 17.5 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/hermes_cli/auth_spotify.py
- Command line
-
hf download hf://SaylorTwift/hermes-agent/hermes_cli/auth_spotify.py
-
curl -L -o auth_spotify.py https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/hermes_cli/auth_spotify.py
17.5 kB
| """Spotify OAuth (loopback PKCE) login, refresh and runtime credentials. | |
| Re-exported from ``hermes_cli/auth.py`` (patch targets unchanged); origin helpers are imported | |
| lazily per function so ``hermes_cli.auth.<helper>`` patches still intercept and no cycle forms. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import uuid | |
| import webbrowser | |
| from datetime import datetime, timezone | |
| from typing import Any, Dict, Optional, Tuple | |
| from urllib.parse import urlencode, urlparse | |
| from hermes_cli.auth_constants import ( | |
| AuthError, DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, DEFAULT_SPOTIFY_API_BASE_URL, DEFAULT_SPOTIFY_REDIRECT_URI, | |
| DEFAULT_SPOTIFY_SCOPE, SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, SPOTIFY_DASHBOARD_URL, SPOTIFY_DOCS_URL, | |
| _spotify_err, httpx, | |
| ) | |
| from hermes_cli.auth_device_flow import ( | |
| _bind_loopback_callback_server, _make_loopback_callback_handler, _pkce_code_challenge, | |
| _pkce_code_verifier, _serve_loopback_callback) | |
| logger = logging.getLogger("hermes_cli.auth") | |
| def _clean(value: Any) -> str: | |
| return str(value or "").strip() | |
| def _spotify_scope_string(raw_scope: Optional[str] = None) -> str: | |
| """Requested scope, whitespace-normalized and de-duplicated (order kept).""" | |
| return " ".join(dict.fromkeys((raw_scope or DEFAULT_SPOTIFY_SCOPE).split())) | |
| def _spotify_setting( | |
| state: Optional[Dict[str, Any]], state_key: str, env_vars: Tuple[str, ...], default: str, *, | |
| explicit: Optional[str] = None, strip_slash: bool = False, | |
| ) -> str: | |
| """First non-empty of explicit arg, env vars (``.env`` aware), stored state, then *default*.""" | |
| from hermes_cli.config import get_env_value | |
| candidates = ( | |
| explicit, *(get_env_value(var) for var in env_vars), | |
| state.get(state_key) if isinstance(state, dict) else None, default, | |
| ) | |
| for candidate in candidates: | |
| cleaned = _clean(candidate) | |
| if strip_slash: | |
| cleaned = cleaned.rstrip("/") | |
| if cleaned: | |
| return cleaned | |
| return default | |
| def _spotify_client_id(explicit: Optional[str] = None, state: Optional[Dict[str, Any]] = None) -> str: | |
| client_id = _spotify_setting( | |
| state, "client_id", ("HERMES_SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_ID"), "", explicit=explicit, | |
| ) | |
| if client_id: | |
| return client_id | |
| raise _spotify_err( | |
| "Spotify client_id is required. Set HERMES_SPOTIFY_CLIENT_ID or pass --client-id.", | |
| "spotify_client_id_missing", | |
| ) | |
| def _spotify_redirect_uri(explicit: Optional[str] = None, state: Optional[Dict[str, Any]] = None) -> str: | |
| return _spotify_setting( | |
| state, "redirect_uri", ("HERMES_SPOTIFY_REDIRECT_URI", "SPOTIFY_REDIRECT_URI"), | |
| DEFAULT_SPOTIFY_REDIRECT_URI, explicit=explicit, | |
| ) | |
| def _spotify_api_base_url(state: Optional[Dict[str, Any]] = None) -> str: | |
| return _spotify_setting( | |
| state, "api_base_url", ("HERMES_SPOTIFY_API_BASE_URL",), DEFAULT_SPOTIFY_API_BASE_URL, strip_slash=True, | |
| ) | |
| def _spotify_accounts_base_url(state: Optional[Dict[str, Any]] = None) -> str: | |
| return _spotify_setting( | |
| state, "accounts_base_url", ("HERMES_SPOTIFY_ACCOUNTS_BASE_URL",), DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, | |
| strip_slash=True, | |
| ) | |
| def _spotify_build_authorize_url( | |
| *, client_id: str, redirect_uri: str, scope: str, state: str, code_challenge: str, | |
| accounts_base_url: str, | |
| ) -> str: | |
| query = urlencode({ | |
| "client_id": client_id, "response_type": "code", "redirect_uri": redirect_uri, | |
| "scope": scope, "state": state, "code_challenge_method": "S256", | |
| "code_challenge": code_challenge, | |
| }) | |
| return f"{accounts_base_url}/authorize?{query}" | |
| def _spotify_validate_redirect_uri(redirect_uri: str) -> tuple[str, int, str]: | |
| parsed = urlparse(redirect_uri) | |
| host = parsed.hostname or "" | |
| problem = ( | |
| "must use http://localhost or http://127.0.0.1." if parsed.scheme != "http" | |
| else "must point to localhost or 127.0.0.1." if host not in {"127.0.0.1", "localhost"} | |
| else "must include an explicit localhost port." if not parsed.port | |
| else None | |
| ) | |
| if problem: | |
| raise _spotify_err(f"Spotify PKCE redirect_uri {problem}", "spotify_redirect_invalid") | |
| return host, parsed.port, parsed.path or "/" | |
| def _spotify_wait_for_callback(redirect_uri: str, *, timeout_seconds: float = 180.0) -> dict[str, Any]: | |
| host, port, path = _spotify_validate_redirect_uri(redirect_uri) | |
| handler_cls, result = _make_loopback_callback_handler(path, display_name="Spotify") | |
| server = _bind_loopback_callback_server( | |
| host, port, handler_cls, err=_spotify_err, bind_failed_code="spotify_callback_bind_failed") | |
| return _serve_loopback_callback( | |
| server, result, timeout_seconds=timeout_seconds, err=_spotify_err, timeout_code="spotify_callback_timeout") | |
| def _spotify_token_payload_to_state( | |
| token_payload: Dict[str, Any], *, client_id: str, redirect_uri: str, requested_scope: str, | |
| accounts_base_url: str, api_base_url: str, previous_state: Optional[Dict[str, Any]] = None, | |
| ) -> Dict[str, Any]: | |
| from hermes_cli.auth import _coerce_ttl_seconds | |
| now = datetime.now(timezone.utc) | |
| expires_in = _coerce_ttl_seconds(token_payload.get("expires_in", 0)) | |
| expires_at = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc) | |
| state = dict(previous_state or {}) | |
| state.update({ | |
| "client_id": client_id, "redirect_uri": redirect_uri, | |
| "accounts_base_url": accounts_base_url, "api_base_url": api_base_url, | |
| "scope": requested_scope, | |
| "granted_scope": str(token_payload.get("scope") or requested_scope).strip(), | |
| "token_type": _clean(token_payload.get("token_type", "Bearer") or "Bearer") or "Bearer", | |
| "access_token": _clean(token_payload.get("access_token")), | |
| "refresh_token": _clean(token_payload.get("refresh_token") or state.get("refresh_token")), | |
| "obtained_at": now.isoformat(), "expires_at": expires_at.isoformat(), | |
| "expires_in": expires_in, "auth_type": "oauth_pkce", | |
| }) | |
| return state | |
| def _spotify_token_post( | |
| accounts_base_url: str, data: Dict[str, str], *, timeout_seconds: float, what: str, | |
| failed_code: str, invalid_code: str, invalid_message: str, failed_suffix: str = "", | |
| relogin_required: bool = False, | |
| ) -> Dict[str, Any]: | |
| """POST to Spotify's ``/api/token`` and return the JSON payload, or raise a shaped AuthError.""" | |
| try: | |
| response = httpx.post( | |
| f"{accounts_base_url}/api/token", | |
| headers={"Content-Type": "application/x-www-form-urlencoded"}, | |
| data=data, | |
| timeout=timeout_seconds, | |
| ) | |
| except Exception as exc: | |
| raise _spotify_err(f"Spotify {what} failed: {exc}", failed_code) from exc | |
| if response.status_code >= 400: | |
| detail = response.text.strip() | |
| raise _spotify_err( | |
| f"Spotify {what} failed.{failed_suffix}" + (f" Response: {detail}" if detail else ""), | |
| failed_code, relogin=relogin_required, | |
| ) | |
| payload = response.json() | |
| if not isinstance(payload, dict) or not _clean(payload.get("access_token")): | |
| raise _spotify_err(invalid_message, invalid_code, relogin=relogin_required) | |
| return payload | |
| def _refresh_spotify_oauth_state(state: Dict[str, Any], *, timeout_seconds: float = 20.0) -> Dict[str, Any]: | |
| refresh_token = _clean(state.get("refresh_token")) | |
| if not refresh_token: | |
| raise _spotify_err( | |
| "Spotify refresh token missing. Run `hermes auth spotify` again.", | |
| "spotify_refresh_token_missing", relogin=True, | |
| ) | |
| client_id = _spotify_client_id(state=state) | |
| accounts_base_url = _spotify_accounts_base_url(state) | |
| payload = _spotify_token_post( | |
| accounts_base_url, | |
| {"grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": client_id}, | |
| timeout_seconds=timeout_seconds, what="token refresh", failed_code="spotify_refresh_failed", | |
| invalid_code="spotify_refresh_invalid", | |
| invalid_message="Spotify refresh response did not include an access_token.", | |
| failed_suffix=" Run `hermes auth spotify` again.", relogin_required=True, | |
| ) | |
| return _spotify_token_payload_to_state( | |
| payload, client_id=client_id, redirect_uri=_spotify_redirect_uri(state=state), | |
| requested_scope=str(state.get("scope") or DEFAULT_SPOTIFY_SCOPE), | |
| accounts_base_url=accounts_base_url, api_base_url=_spotify_api_base_url(state), | |
| previous_state=state, | |
| ) | |
| def resolve_spotify_runtime_credentials( | |
| *, force_refresh: bool = False, refresh_if_expiring: bool = True, | |
| refresh_skew_seconds: int = SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, | |
| ) -> Dict[str, Any]: | |
| from hermes_cli.auth import _auth_store_lock, _is_expiring, _load_auth_store, _load_provider_state, _quarantine_flat_oauth_state, _refresh_spotify_oauth_state, _save_auth_store, _store_provider_state | |
| with _auth_store_lock(): | |
| auth_store = _load_auth_store() | |
| state = _load_provider_state(auth_store, "spotify") | |
| if not state: | |
| raise _spotify_err( | |
| "Spotify is not authenticated. Run `hermes auth spotify` first.", "spotify_auth_missing", relogin=True, | |
| ) | |
| should_refresh = bool(force_refresh) | |
| if not should_refresh and refresh_if_expiring: | |
| should_refresh = _is_expiring(state.get("expires_at"), refresh_skew_seconds) | |
| if should_refresh: | |
| try: | |
| state = _refresh_spotify_oauth_state(state) | |
| _store_provider_state(auth_store, "spotify", state, set_active=False) | |
| _save_auth_store(auth_store) | |
| except AuthError as exc: | |
| if exc.relogin_required and state.get("refresh_token"): | |
| _quarantine_flat_oauth_state(state, "spotify", exc) | |
| try: | |
| _store_provider_state(auth_store, "spotify", state, set_active=False) | |
| _save_auth_store(auth_store) | |
| except Exception as _save_exc: | |
| logger.debug("Spotify OAuth: failed to persist quarantined state: %s", _save_exc) | |
| raise | |
| access_token = _clean(state.get("access_token")) | |
| if not access_token: | |
| raise _spotify_err( | |
| "Spotify access token missing. Run `hermes auth spotify` again.", | |
| "spotify_access_token_missing", relogin=True, | |
| ) | |
| return { | |
| "provider": "spotify", "access_token": access_token, "api_key": access_token, | |
| "token_type": str(state.get("token_type", "Bearer") or "Bearer"), | |
| "base_url": _spotify_api_base_url(state), | |
| "scope": _clean(state.get("granted_scope") or state.get("scope")), | |
| "client_id": _spotify_client_id(state=state), | |
| "redirect_uri": _spotify_redirect_uri(state=state), "expires_at": state.get("expires_at"), | |
| "refresh_token": _clean(state.get("refresh_token")), | |
| } | |
| def get_spotify_auth_status() -> Dict[str, Any]: | |
| from hermes_cli.auth import _is_expiring, get_provider_auth_state | |
| state = get_provider_auth_state("spotify") | |
| if not state: | |
| return {"logged_in": False} | |
| expires_at = state.get("expires_at") | |
| refresh_token = _clean(state.get("refresh_token")) | |
| return { | |
| "logged_in": bool(refresh_token or not _is_expiring(expires_at, 0)), | |
| "auth_type": state.get("auth_type", "oauth_pkce"), "client_id": state.get("client_id"), | |
| "redirect_uri": state.get("redirect_uri"), | |
| "scope": state.get("granted_scope") or state.get("scope"), "expires_at": expires_at, | |
| "api_base_url": state.get("api_base_url"), "has_refresh_token": bool(refresh_token), | |
| } | |
| def _spotify_interactive_setup(redirect_uri_hint: str) -> str: | |
| """Walk the user through creating a Spotify developer app; persist the client_id to ~/.hermes/.env.""" | |
| from hermes_cli.auth import _is_remote_session | |
| from hermes_cli.config import save_env_value | |
| print( | |
| f"\n{'=' * 70}\nSpotify first-time setup\n{'=' * 70}\n\n" | |
| "Spotify requires every user to register their own lightweight\n" | |
| "developer app. This takes about two minutes and only has to be\n" | |
| "done once per machine.\n\n" | |
| f"Full guide: {SPOTIFY_DOCS_URL}\n\n" | |
| "Steps:\n" | |
| f" 1. Opening {SPOTIFY_DASHBOARD_URL} in your browser...\n" | |
| " 2. Click 'Create app' and fill in:\n" | |
| " App name: anything (e.g. hermes-agent)\n" | |
| " Description: anything\n" | |
| f" Redirect URI: {redirect_uri_hint}\n" | |
| " API/SDK: Web API\n" | |
| " 3. Agree to the terms, click Save.\n" | |
| " 4. Open the app's Settings page and copy the Client ID.\n" | |
| " 5. Paste it below.\n" | |
| ) | |
| if not _is_remote_session(): | |
| try: | |
| webbrowser.open(SPOTIFY_DASHBOARD_URL) | |
| except Exception: | |
| pass | |
| from hermes_cli.cli_output import line_input | |
| try: | |
| raw = line_input("Spotify Client ID: ").strip() | |
| except (EOFError, KeyboardInterrupt): | |
| print() | |
| raise SystemExit("Spotify setup cancelled.") | |
| if not raw: | |
| print(f"\nNo Client ID entered. See {SPOTIFY_DOCS_URL} for the full guide.") | |
| raise SystemExit("Spotify setup cancelled: empty Client ID.") | |
| # Persist so later runs skip the wizard; only pin a NON-default redirect URI. | |
| save_env_value("HERMES_SPOTIFY_CLIENT_ID", raw) | |
| if redirect_uri_hint and redirect_uri_hint != DEFAULT_SPOTIFY_REDIRECT_URI: | |
| save_env_value("HERMES_SPOTIFY_REDIRECT_URI", redirect_uri_hint) | |
| print("\nSaved HERMES_SPOTIFY_CLIENT_ID to ~/.hermes/.env\n") | |
| return raw | |
| def login_spotify_command(args) -> None: | |
| from hermes_cli.auth import _auth_store_lock, _can_open_graphical_browser, _is_remote_session, _load_auth_store, _print_loopback_ssh_hint, _save_auth_store, _store_provider_state, get_provider_auth_state | |
| existing_state = get_provider_auth_state("spotify") or {} | |
| # No client_id anywhere -> wizard instead of "HERMES_SPOTIFY_CLIENT_ID is required". | |
| try: | |
| client_id = _spotify_client_id(getattr(args, "client_id", None), existing_state) | |
| except AuthError as exc: | |
| if getattr(exc, "code", "") != "spotify_client_id_missing": | |
| raise | |
| client_id = _spotify_interactive_setup( | |
| redirect_uri_hint=getattr(args, "redirect_uri", None) or DEFAULT_SPOTIFY_REDIRECT_URI, | |
| ) | |
| redirect_uri = _spotify_redirect_uri(getattr(args, "redirect_uri", None), existing_state) | |
| scope = _spotify_scope_string(getattr(args, "scope", None) or existing_state.get("scope")) | |
| accounts_base_url = _spotify_accounts_base_url(existing_state) | |
| api_base_url = _spotify_api_base_url(existing_state) | |
| open_browser = not getattr(args, "no_browser", False) | |
| code_verifier = _pkce_code_verifier() | |
| state_nonce = uuid.uuid4().hex | |
| authorize_url = _spotify_build_authorize_url( | |
| client_id=client_id, redirect_uri=redirect_uri, scope=scope, state=state_nonce, | |
| code_challenge=_pkce_code_challenge(code_verifier), accounts_base_url=accounts_base_url, | |
| ) | |
| print( | |
| f"Starting Spotify PKCE login...\nClient ID: {client_id}\nRedirect URI: {redirect_uri}\n" | |
| "Make sure this redirect URI is allow-listed in your Spotify app settings.\n\n" | |
| f"Open this URL to authorize Hermes:\n{authorize_url}\n\nFull setup guide: {SPOTIFY_DOCS_URL}\n" | |
| ) | |
| _print_loopback_ssh_hint(redirect_uri, docs_url=SPOTIFY_DOCS_URL) | |
| if open_browser and not _is_remote_session() and _can_open_graphical_browser(): | |
| try: | |
| opened = webbrowser.open(authorize_url) | |
| except Exception: | |
| opened = False | |
| print( | |
| "Browser opened for Spotify authorization." if opened | |
| else "Could not open the browser automatically; use the URL above." | |
| ) | |
| callback = _spotify_wait_for_callback(redirect_uri, timeout_seconds=float(getattr(args, "timeout", None) or 180.0)) | |
| if callback.get("error"): | |
| raise SystemExit(f"Spotify authorization failed: {callback.get('error_description') or callback['error']}") | |
| if callback.get("state") != state_nonce: | |
| raise SystemExit("Spotify authorization failed: state mismatch.") | |
| token_payload = _spotify_token_post( | |
| accounts_base_url, | |
| { | |
| "client_id": client_id, "grant_type": "authorization_code", | |
| "code": str(callback.get("code") or ""), "redirect_uri": redirect_uri, | |
| "code_verifier": code_verifier, | |
| }, | |
| timeout_seconds=float(getattr(args, "timeout", None) or 20.0), | |
| what="token exchange", failed_code="spotify_token_exchange_failed", | |
| invalid_code="spotify_token_exchange_invalid", | |
| invalid_message="Spotify token response did not include an access_token.", | |
| ) | |
| spotify_state = _spotify_token_payload_to_state( | |
| token_payload, client_id=client_id, redirect_uri=redirect_uri, requested_scope=scope, | |
| accounts_base_url=accounts_base_url, api_base_url=api_base_url, | |
| ) | |
| with _auth_store_lock(): | |
| auth_store = _load_auth_store() | |
| _store_provider_state(auth_store, "spotify", spotify_state, set_active=False) | |
| saved_to = _save_auth_store(auth_store) | |
| print( | |
| f"Spotify login successful!\n Auth state: {saved_to}\n" | |
| f" Provider state saved under providers.spotify\n Docs: {SPOTIFY_DOCS_URL}" | |
| ) | |