Files
alembic/app/routers/connect.py
T
andrew 9976c430aa 0.6.1: Fix Spotify OAuth scope missing user-library-read
connect.py's OAuth scope request (playlist-read-private
playlist-read-collaborative) didn't match what sldl's own built-in login
flow requests (same two, plus user-library-read). The narrower scope alembic
granted let sldl refresh a valid access token, but Spotify then returned 403
Forbidden loading the playlist regardless of ownership, which sldl turns
into an unhandled-exception crash (exit 134) instead of a clean scope error.
Found by reproducing a friend's crash: his playlist was his own, connected
via his own OAuth, correct redirect URI -- ruling out the ownership/
visibility explanation and pointing at the scope mismatch instead.

Also:
- run-playlist.sh: detect exit 134 specifically and log a pointed hint
  (distinguishing the Forbidden-loading-playlist case from any other
  unhandled sldl exception) instead of just "sldl finished with exit code
  134" with no context.
- README: document that anyone who connected Spotify before this version
  needs to click Connect Spotify again -- existing refresh tokens carry the
  old, narrower scope and don't upgrade themselves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 08:07:30 -06:00

107 lines
4.5 KiB
Python

"""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://<host>/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)