Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2685,9 +2685,31 @@ def resolve_spotify_runtime_credentials(
if not should_refresh and refresh_if_expiring:
should_refresh = _is_expiring(state.get("expires_at"), refresh_skew_seconds)
if should_refresh:
state = _refresh_spotify_oauth_state(state)
_store_provider_state(auth_store, "spotify", state, set_active=False)
_save_auth_store(auth_store)
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"):
# Terminal refresh failure — clear dead tokens from auth.json
# so subsequent calls fail fast without a network retry.
# Mirrors the Nous / xAI-OAuth / Codex-OAuth / MiniMax pattern.
for _k in ("access_token", "refresh_token", "expires_at", "expires_in", "obtained_at"):
state.pop(_k, None)
state["last_auth_error"] = {
"provider": "spotify",
"code": exc.code or "refresh_failed",
"message": str(exc),
"reason": "runtime_refresh_failure",
"relogin_required": True,
"at": datetime.now(timezone.utc).isoformat(),
}
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 = str(state.get("access_token", "") or "").strip()
if not access_token:
Expand Down
119 changes: 119 additions & 0 deletions tests/hermes_cli/test_spotify_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest

from hermes_cli import auth as auth_mod
from hermes_cli.auth import AuthError, resolve_spotify_runtime_credentials


def test_store_provider_state_can_skip_active_provider() -> None:
Expand Down Expand Up @@ -181,3 +182,121 @@ def test_spotify_interactive_setup_empty_aborts(
env_path = tmp_path / ".env"
if env_path.exists():
assert "HERMES_SPOTIFY_CLIENT_ID" not in env_path.read_text()


# ---------------------------------------------------------------------------
# Quarantine: terminal refresh failure clears dead tokens (#28139)
# ---------------------------------------------------------------------------

_STALE_SPOTIFY_STATE = {
"client_id": "test-client",
"redirect_uri": "http://127.0.0.1:43827/spotify/callback",
"api_base_url": auth_mod.DEFAULT_SPOTIFY_API_BASE_URL,
"accounts_base_url": auth_mod.DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL,
"scope": auth_mod.DEFAULT_SPOTIFY_SCOPE,
"granted_scope": auth_mod.DEFAULT_SPOTIFY_SCOPE,
"token_type": "Bearer",
"access_token": "dead-access-token",
"refresh_token": "dead-refresh-token",
"expires_at": "2000-01-01T00:00:00+00:00",
"expires_in": 3600,
"obtained_at": "2000-01-01T00:00:00+00:00",
"auth_type": "oauth_pkce",
}


def _seed_spotify_state(tmp_path, state: dict) -> None:
with auth_mod._auth_store_lock():
store = auth_mod._load_auth_store()
store["active_provider"] = "nous"
auth_mod._store_provider_state(store, "spotify", state, set_active=False)
auth_mod._save_auth_store(store)


def test_resolve_credentials_quarantines_dead_tokens_on_terminal_refresh_failure(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Terminal refresh failure (relogin_required=True + refresh_token present)
must clear access_token/refresh_token/expires_* from auth.json and write a
last_auth_error marker so subsequent calls fail fast without a network retry.
Mirrors Nous / xAI-OAuth / Codex-OAuth / MiniMax quarantine pattern.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_seed_spotify_state(tmp_path, dict(_STALE_SPOTIFY_STATE))

def _terminal_refresh(_state, **_kw):
raise AuthError(
"Spotify token refresh failed. Run `hermes auth spotify` again.",
provider="spotify",
code="spotify_refresh_failed",
relogin_required=True,
)

monkeypatch.setattr(auth_mod, "_refresh_spotify_oauth_state", _terminal_refresh)

with pytest.raises(AuthError) as exc_info:
resolve_spotify_runtime_credentials(force_refresh=True)

assert exc_info.value.code == "spotify_refresh_failed"
assert exc_info.value.relogin_required is True

persisted = auth_mod.get_provider_auth_state("spotify")
assert persisted is not None

# Dead OAuth fields must be cleared.
assert "access_token" not in persisted
assert "refresh_token" not in persisted
assert "expires_at" not in persisted
assert "expires_in" not in persisted
assert "obtained_at" not in persisted

# Non-credential metadata must be preserved.
assert persisted["client_id"] == "test-client"
assert persisted["api_base_url"] == auth_mod.DEFAULT_SPOTIFY_API_BASE_URL
assert persisted["accounts_base_url"] == auth_mod.DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL

# Structured diagnostic blob must be written.
err = persisted.get("last_auth_error")
assert isinstance(err, dict)
assert err["provider"] == "spotify"
assert err["code"] == "spotify_refresh_failed"
assert err["reason"] == "runtime_refresh_failure"
assert err["relogin_required"] is True
assert "at" in err

# Active provider must be unchanged.
assert auth_mod.get_active_provider() == "nous"


def test_resolve_credentials_does_not_quarantine_on_transient_refresh_failure(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Transient refresh failure (relogin_required=False, e.g. 429 / 5xx) must
NOT trigger the quarantine path — tokens stay on disk for the next attempt.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_seed_spotify_state(tmp_path, dict(_STALE_SPOTIFY_STATE))

def _transient_refresh(_state, **_kw):
raise AuthError(
"Spotify token refresh failed: connection error",
provider="spotify",
code="spotify_refresh_failed",
relogin_required=False,
)

monkeypatch.setattr(auth_mod, "_refresh_spotify_oauth_state", _transient_refresh)

with pytest.raises(AuthError) as exc_info:
resolve_spotify_runtime_credentials(force_refresh=True)

assert exc_info.value.relogin_required is False

# Tokens must be untouched — no quarantine on transient errors.
persisted = auth_mod.get_provider_auth_state("spotify")
assert persisted is not None
assert persisted["refresh_token"] == "dead-refresh-token"
assert persisted["access_token"] == "dead-access-token"
assert "last_auth_error" not in persisted
23 changes: 23 additions & 0 deletions tests/tools/test_spotify_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import pytest

from hermes_cli.auth import AuthError
from plugins.spotify import client as spotify_mod
from plugins.spotify import tools as spotify_tool

Expand Down Expand Up @@ -297,3 +298,25 @@ def get_recently_played(self, **kw):
payload = json.loads(spotify_tool._handle_spotify_playback({"action": "recently_played", "limit": 5}))
assert seen and seen[0]["limit"] == 5
assert isinstance(payload, dict)


def test_client_wraps_invalid_grant_as_spotify_auth_required_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""SpotifyClient._resolve_runtime wraps AuthError(code=spotify_refresh_invalid_grant) into SpotifyAuthRequiredError."""

def _raise_invalid_grant(**kwargs):
raise AuthError(
"Spotify refresh token has expired or was revoked. Run `hermes auth spotify` again.",
provider="spotify",
code="spotify_refresh_invalid_grant",
relogin_required=True,
)

monkeypatch.setattr(
spotify_mod,
"resolve_spotify_runtime_credentials",
_raise_invalid_grant,
)
with pytest.raises(spotify_mod.SpotifyAuthRequiredError, match="expired or was revoked"):
spotify_mod.SpotifyClient()
2 changes: 1 addition & 1 deletion website/docs/user-guide/features/spotify.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Spotify

Hermes can control Spotify directly — playback, queue, search, playlists, saved tracks/albums, and listening history — using Spotify's official Web API with PKCE OAuth. Tokens are stored in `~/.hermes/auth.json` and refreshed automatically on 401; you only log in once per machine.
Hermes can control Spotify directly — playback, queue, search, playlists, saved tracks/albums, and listening history — using Spotify's official Web API with PKCE OAuth. Tokens are stored in `~/.hermes/auth.json` and refreshed automatically on 401; you only log in once per machine (refresh tokens expire after ~6 months; re-run `hermes auth spotify` when they do).

Unlike Hermes' built-in OAuth integrations (Google, GitHub Copilot, Codex), Spotify requires every user to register their own lightweight developer app. Spotify does not let third parties ship a public OAuth app that anyone can use. It takes about two minutes and `hermes auth spotify` walks you through it.

Expand Down