From af3b77a7d02f273fc0837bffe95b3a5194c61347 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Mon, 10 Aug 2026 10:43:49 +0200 Subject: [PATCH] feat: add Spotify authentication and sessions --- backend/app/adapters/spotify/auth.py | 137 ++++++++++++++++++++++++ backend/app/adapters/spotify/errors.py | 31 ++++++ backend/app/adapters/spotify/session.py | 65 +++++++++++ backend/app/domain/models.py | 16 +++ backend/tests/test_spotify_auth.py | 45 ++++++++ 5 files changed, 294 insertions(+) create mode 100644 backend/app/adapters/spotify/auth.py create mode 100644 backend/app/adapters/spotify/errors.py create mode 100644 backend/app/adapters/spotify/session.py create mode 100644 backend/app/domain/models.py create mode 100644 backend/tests/test_spotify_auth.py diff --git a/backend/app/adapters/spotify/auth.py b/backend/app/adapters/spotify/auth.py new file mode 100644 index 0000000..fb78635 --- /dev/null +++ b/backend/app/adapters/spotify/auth.py @@ -0,0 +1,137 @@ +"""Spotify Authorization Code with PKCE helpers and token exchange.""" + +import base64 +import hashlib +import secrets +import time +from dataclasses import dataclass +from urllib.parse import urlencode + +import httpx2 + +from app.adapters.spotify.errors import SpotifyAuthenticationError + +AUTHORIZE_URL = "https://accounts.spotify.com/authorize" +TOKEN_URL = "https://accounts.spotify.com/api/token" +SCOPES = ( + "user-top-read user-library-read user-read-recently-played " + "playlist-modify-public playlist-modify-private " + "user-read-playback-state user-modify-playback-state" +) +TOKEN_EXPIRY_SKEW_SECONDS = 60.0 + + +@dataclass(frozen=True) +class TokenSet: + """An access token and its refresh credentials.""" + + access_token: str + refresh_token: str + expires_at: float + + @property + def is_expired(self) -> bool: + """Return whether the access token is expired within the safety skew.""" + return time.monotonic() >= self.expires_at - TOKEN_EXPIRY_SKEW_SECONDS + + +def generate_code_verifier() -> str: + """Create a high-entropy PKCE verifier within the allowed length.""" + return secrets.token_urlsafe(64) + + +def derive_code_challenge(verifier: str) -> str: + """Derive an unpadded base64url S256 challenge from a PKCE verifier.""" + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def build_authorize_url( + client_id: str, + redirect_uri: str, + state: str, + code_challenge: str, +) -> str: + """Build the Spotify consent URL for the required scopes.""" + query = urlencode( + { + "client_id": client_id, + "response_type": "code", + "redirect_uri": redirect_uri, + "state": state, + "scope": SCOPES, + "code_challenge_method": "S256", + "code_challenge": code_challenge, + } + ) + return f"{AUTHORIZE_URL}?{query}" + + +async def exchange_authorization_code( + http: httpx2.AsyncClient, + *, + client_id: str, + redirect_uri: str, + code: str, + code_verifier: str, +) -> TokenSet: + """Exchange a Spotify authorization code for an initial token set.""" + response = await http.post( + TOKEN_URL, + data={ + "client_id": client_id, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + }, + ) + return _parse_token_response(response, existing_refresh_token=None) + + +async def refresh_access_token( + http: httpx2.AsyncClient, + *, + client_id: str, + tokens: TokenSet, +) -> TokenSet: + """Refresh an access token while retaining an omitted refresh token.""" + response = await http.post( + TOKEN_URL, + data={ + "client_id": client_id, + "grant_type": "refresh_token", + "refresh_token": tokens.refresh_token, + }, + ) + return _parse_token_response(response, existing_refresh_token=tokens.refresh_token) + + +def _parse_token_response( + response: httpx2.Response, + existing_refresh_token: str | None, +) -> TokenSet: + if not response.is_success: + raise SpotifyAuthenticationError( + f"Spotify token request failed with status {response.status_code}" + ) + + try: + payload = response.json() + access_token = payload["access_token"] + expires_in = payload["expires_in"] + refresh_token = payload.get("refresh_token", existing_refresh_token) + if ( + not isinstance(access_token, str) + or not isinstance(expires_in, int | float) + or not isinstance(refresh_token, str) + ): + raise TypeError + except (TypeError, KeyError, ValueError) as error: + raise SpotifyAuthenticationError("Spotify returned an invalid token response") from error + + return TokenSet( + access_token=access_token, + refresh_token=refresh_token, + expires_at=time.monotonic() + float(expires_in), + ) diff --git a/backend/app/adapters/spotify/errors.py b/backend/app/adapters/spotify/errors.py new file mode 100644 index 0000000..c1e82de --- /dev/null +++ b/backend/app/adapters/spotify/errors.py @@ -0,0 +1,31 @@ +"""Typed failures raised by the Spotify adapter.""" + + +class SpotifyError(Exception): + """Base class for Spotify adapter failures.""" + + +class SpotifyAuthenticationError(SpotifyError): + """Spotify rejected authentication or token refresh.""" + + +class SpotifyRateLimitedError(SpotifyError): + """Spotify rate limited a request that could not be retried.""" + + def __init__(self, retry_after_seconds: float | None) -> None: + """Record Spotify's requested delay without exposing request data.""" + super().__init__("Spotify rate limit exceeded") + self.retry_after_seconds = retry_after_seconds + + +class SpotifyUnavailableError(SpotifyError): + """Spotify returned a server-side failure.""" + + +class SpotifyRequestError(SpotifyError): + """Spotify rejected a non-authenticated API request.""" + + def __init__(self, status_code: int) -> None: + """Record the response status without exposing response content.""" + super().__init__(f"Spotify request failed with status {status_code}") + self.status_code = status_code diff --git a/backend/app/adapters/spotify/session.py b/backend/app/adapters/spotify/session.py new file mode 100644 index 0000000..613933b --- /dev/null +++ b/backend/app/adapters/spotify/session.py @@ -0,0 +1,65 @@ +"""In-memory Spotify login and session state.""" + +import asyncio +import secrets +import time +from dataclasses import dataclass, field + +from app.adapters.spotify.auth import TokenSet + +PENDING_LOGIN_LIFETIME_SECONDS = 600.0 + + +@dataclass +class SpotifySession: + """Authenticated Spotify identity and refresh coordination state.""" + + tokens: TokenSet + account_id: str + display_name: str + refresh_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + +class SessionStore: + """Store Spotify sessions behind opaque cookie-safe identifiers.""" + + def __init__(self) -> None: + """Create an empty session store.""" + self._sessions: dict[str, SpotifySession] = {} + + def create(self, session: SpotifySession) -> str: + """Store a session and return its opaque identifier.""" + session_id = secrets.token_urlsafe(32) + self._sessions[session_id] = session + return session_id + + def get(self, session_id: str) -> SpotifySession | None: + """Return a session by identifier when present.""" + return self._sessions.get(session_id) + + def remove(self, session_id: str) -> None: + """Remove a session if it exists.""" + self._sessions.pop(session_id, None) + + +class PendingLogins: + """Store short-lived, single-use PKCE verifiers by OAuth state.""" + + def __init__(self) -> None: + """Create an empty pending-login store.""" + self._entries: dict[str, tuple[str, float]] = {} + + def add(self, state: str, code_verifier: str) -> None: + """Store a PKCE verifier for a newly issued OAuth state.""" + self._entries[state] = (code_verifier, time.monotonic()) + + def pop(self, state: str) -> str | None: + """Consume a verifier unless its OAuth state is unknown or expired.""" + entry = self._entries.pop(state, None) + if entry is None: + return None + + code_verifier, created_at = entry + if time.monotonic() - created_at >= PENDING_LOGIN_LIFETIME_SECONDS: + return None + return code_verifier diff --git a/backend/app/domain/models.py b/backend/app/domain/models.py new file mode 100644 index 0000000..24e6beb --- /dev/null +++ b/backend/app/domain/models.py @@ -0,0 +1,16 @@ +"""Pure domain models shared across application boundaries.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Track: + """A Spotify track expressed without transport-specific data.""" + + id: str + uri: str + title: str + artists: tuple[str, ...] + album_name: str + album_art_url: str | None + external_url: str | None diff --git a/backend/tests/test_spotify_auth.py b/backend/tests/test_spotify_auth.py new file mode 100644 index 0000000..c9f0565 --- /dev/null +++ b/backend/tests/test_spotify_auth.py @@ -0,0 +1,45 @@ +"""Tests for Spotify PKCE and token handling.""" + +import asyncio +import base64 +import hashlib +import time + +import httpx2 + +from app.adapters.spotify.auth import ( + TokenSet, + derive_code_challenge, + refresh_access_token, +) + + +def test_code_challenge_is_unpadded_base64url_sha256() -> None: + verifier = "a-fixed-code-verifier" + expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()) + + challenge = derive_code_challenge(verifier) + + assert challenge == expected.rstrip(b"=").decode() + assert "=" not in challenge + + +def test_refresh_keeps_existing_refresh_token_when_omitted() -> None: + async def run() -> None: + async def handler(request: httpx2.Request) -> httpx2.Response: + assert request.url == "https://accounts.spotify.com/api/token" + return httpx2.Response(200, json={"access_token": "new", "expires_in": 3600}) + + tokens = TokenSet("old", "keep-me", time.monotonic() + 3600) + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: + refreshed = await refresh_access_token(http, client_id="client", tokens=tokens) + + assert refreshed.access_token == "new" + assert refreshed.refresh_token == "keep-me" + + asyncio.run(run()) + + +def test_token_expiry_uses_sixty_second_skew() -> None: + assert TokenSet("access", "refresh", time.monotonic() + 59).is_expired + assert not TokenSet("access", "refresh", time.monotonic() + 61).is_expired