feat: add Spotify authentication and sessions
This commit is contained in:
parent
000b435b9a
commit
af3b77a7d0
5 changed files with 294 additions and 0 deletions
137
backend/app/adapters/spotify/auth.py
Normal file
137
backend/app/adapters/spotify/auth.py
Normal 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),
|
||||
)
|
||||
31
backend/app/adapters/spotify/errors.py
Normal file
31
backend/app/adapters/spotify/errors.py
Normal file
|
|
@ -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
|
||||
65
backend/app/adapters/spotify/session.py
Normal file
65
backend/app/adapters/spotify/session.py
Normal 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
|
||||
16
backend/app/domain/models.py
Normal file
16
backend/app/domain/models.py
Normal file
|
|
@ -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
|
||||
45
backend/tests/test_spotify_auth.py
Normal file
45
backend/tests/test_spotify_auth.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue