feat: add Spotify client and auth routes

This commit is contained in:
Justin Visser 2026-08-10 11:31:06 +02:00
parent cdab1b4dd5
commit 6769833f7e
11 changed files with 934 additions and 9 deletions

97
backend/app/api/routes.py Normal file
View file

@ -0,0 +1,97 @@
"""HTTP routes for Spotify login and session management."""
from typing import cast
import httpx2
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, RedirectResponse, Response
from app.adapters.spotify.login import begin_login, complete_login
from app.adapters.spotify.session import PendingLogins, SessionStore
from app.config import Settings
SESSION_COOKIE_NAME = "discovery_session"
router = APIRouter()
@router.get("/api/auth/login")
def login(request: Request) -> RedirectResponse:
"""Start Spotify Authorization Code with PKCE login."""
application_settings = cast(Settings, request.app.state.settings)
pending_logins = cast(PendingLogins, request.app.state.pending_logins)
authorize_url = begin_login(application_settings, pending_logins)
return RedirectResponse(authorize_url, status_code=307)
@router.get("/callback")
async def callback(
request: Request,
code: str | None = None,
state: str | None = None,
error: str | None = None,
) -> RedirectResponse:
"""Complete Spotify login and establish an opaque cookie session."""
if error is not None or code is None or state is None:
return _login_error_redirect()
application_settings = cast(Settings, request.app.state.settings)
http = cast(httpx2.AsyncClient, request.app.state.http)
pending_logins = cast(PendingLogins, request.app.state.pending_logins)
session_store = cast(SessionStore, request.app.state.session_store)
session_id = await complete_login(
http,
application_settings,
pending_logins,
session_store,
code,
state,
)
if session_id is None:
return _login_error_redirect()
response = RedirectResponse("/", status_code=307)
response.set_cookie(
SESSION_COOKIE_NAME,
session_id,
httponly=True,
samesite="lax",
path="/",
secure=application_settings.session_cookie_secure,
)
return response
@router.get("/api/auth/me")
def current_session(request: Request) -> JSONResponse:
"""Return the display name for a valid application session."""
session_id = request.cookies.get(SESSION_COOKIE_NAME)
session_store = cast(SessionStore, request.app.state.session_store)
session = session_store.get(session_id) if session_id is not None else None
if session is None:
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
return JSONResponse({"display_name": session.display_name})
@router.post("/api/auth/logout", status_code=204)
def logout(request: Request) -> Response:
"""Remove the current application session and clear its cookie."""
session_id = request.cookies.get(SESSION_COOKIE_NAME)
session_store = cast(SessionStore, request.app.state.session_store)
if session_id is not None:
session_store.remove(session_id)
application_settings = cast(Settings, request.app.state.settings)
response = Response(status_code=204)
response.delete_cookie(
SESSION_COOKIE_NAME,
path="/",
secure=application_settings.session_cookie_secure,
httponly=True,
samesite="lax",
)
return response
def _login_error_redirect() -> RedirectResponse:
return RedirectResponse("/?login=error", status_code=307)