"""Spotify OAuth "Connect Spotify" flow. Not on the /auth prefix or the /settings/credentials prefix: Spotify's app dashboard needs one fixed redirect URI to register, and this is it in full: https:///connect/spotify/callback. Unlike Pocket ID (app/security/oidc.py), Spotify's client id/secret live in the encrypted credential store, not app.settings -- so this builds the authorize/token requests directly with httpx instead of going through an authlib client registered once at import time with fixed credentials. """ import base64 import secrets import urllib.parse import httpx from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import RedirectResponse from app.db import get_db from app.security.deps import require_auth from app.services import credential_service router = APIRouter(tags=["spotify-connect"]) AUTHORIZE_URL = "https://accounts.spotify.com/authorize" TOKEN_URL = "https://accounts.spotify.com/api/token" # Matches exactly what sldl's own built-in OAuth login flow requests (seen in # its printed authorize URL: user-library-read + these two) -- granting a # narrower scope than sldl expects gets a token sldl can refresh fine but # then gets 403 Forbidden from Spotify partway through loading a playlist, # which sldl turns into an unhandled-exception crash (exit 134) rather than a # clean scope error. SCOPES = "playlist-read-private playlist-read-collaborative user-library-read" def _callback_redirect_uri(request: Request) -> str: """request.url_for() reflects the scheme uvicorn saw on the actual TCP connection, not what the reverse proxy served over -- alembic sits behind a TLS-terminating proxy (per every deployment example in the README) and uvicorn isn't told to trust its X-Forwarded-Proto, so this comes back `http://`. Spotify requires an exact scheme match against the redirect URI registered in its app dashboard (always https for a real hostname), so force it here rather than trust the raw connection's scheme.""" return str(request.url_for("spotify_callback").replace(scheme="https")) @router.get("/connect/spotify") async def connect_spotify(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)): client_id = credential_service.get_scope(db, "spotify").get("client_id") if not client_id: # Nothing to connect to yet -- send them to save client_id/secret first. return RedirectResponse(url="/settings/credentials", status_code=303) # CSRF guard on the callback: the state we get back must match what we # handed out for this session, not just be present. state = secrets.token_urlsafe(24) request.session["spotify_oauth_state"] = state params = { "client_id": client_id, "response_type": "code", "redirect_uri": _callback_redirect_uri(request), "scope": SCOPES, "state": state, } return RedirectResponse(url=f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}") @router.get("/connect/spotify/callback", name="spotify_callback") async def spotify_callback(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)): if error := request.query_params.get("error"): raise HTTPException(400, f"Spotify authorization failed: {error}") state = request.query_params.get("state") if not state or state != request.session.pop("spotify_oauth_state", None): raise HTTPException(400, "Spotify authorization state mismatch") code = request.query_params.get("code") if not code: raise HTTPException(400, "Spotify did not return an authorization code") creds = credential_service.get_scope(db, "spotify") client_id = creds.get("client_id") client_secret = creds.get("client_secret") if not client_id or not client_secret: raise HTTPException(400, "Spotify client id/secret not configured") basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() resp = httpx.post( TOKEN_URL, data={ "grant_type": "authorization_code", "code": code, "redirect_uri": _callback_redirect_uri(request), }, headers={"Authorization": f"Basic {basic}"}, timeout=15, ) resp.raise_for_status() refresh_token = resp.json().get("refresh_token") if not refresh_token: raise HTTPException(400, "Spotify did not return a refresh token") credential_service.set_credentials(db, "spotify", {"refresh_token": refresh_token}) return RedirectResponse(url="/settings/credentials", status_code=303)