"""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.`` 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}" )