feat: add Spotify authentication and sessions

This commit is contained in:
Justin Visser 2026-08-10 11:31:06 +02:00
parent 000b435b9a
commit cdab1b4dd5
5 changed files with 304 additions and 0 deletions

View file

@ -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),
)

View file

@ -0,0 +1,41 @@
"""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, reason: str | None = None) -> None:
"""Record Spotify's requested delay without exposing request data."""
super().__init__("Spotify rate limit exceeded")
self.retry_after_seconds = retry_after_seconds
self.reason = reason
class SpotifyUnavailableError(SpotifyError):
"""Spotify returned a server-side failure."""
def __init__(self, status_code: int, message: str | None = None) -> None:
"""Record safe details from a Spotify server failure."""
generic_message = f"Spotify is unavailable with status {status_code}"
super().__init__(message if message is not None else generic_message)
self.status_code = status_code
self.message = message
class SpotifyRequestError(SpotifyError):
"""Spotify rejected a non-authenticated API request."""
def __init__(self, status_code: int, message: str | None = None) -> None:
"""Record safe details from a rejected Spotify request."""
generic_message = f"Spotify request failed with status {status_code}"
super().__init__(message if message is not None else generic_message)
self.status_code = status_code
self.message = message

View file

@ -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