feat: add Spotify authentication and sessions
This commit is contained in:
parent
000b435b9a
commit
cdab1b4dd5
5 changed files with 304 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),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue