From 71f55ab20d0da52e3e56d42d712b413598b18a5f Mon Sep 17 00:00:00 2001 From: David Madl Date: Thu, 14 May 2026 01:37:23 +0200 Subject: [PATCH] feat: allow redirect_uri param to spotify login, for Lockstep Demo app flow --- api.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/api.py b/api.py index d63ea09..c1c98cc 100644 --- a/api.py +++ b/api.py @@ -21,8 +21,9 @@ import sqlite3 from base64 import b64encode from datetime import datetime, timedelta, timezone import json +from urllib.parse import urlencode -from flask import Flask, request, session, jsonify, make_response +from flask import Flask, request, session, jsonify, make_response, redirect, url_for from werkzeug.middleware.proxy_fix import ProxyFix from authomatic import Authomatic from authomatic.adapters import WerkzeugAdapter @@ -93,13 +94,22 @@ PROVIDER_ID_MAP = list(AUTHOMATIC_PROVIDER_ID_MAP) + [Spotify] SPOTIFY_CLIENT_ID = os.environ["SPOTIFY_CLIENT_ID"] SPOTIFY_CLIENT_SECRET = os.environ["SPOTIFY_CLIENT_SECRET"] -# Must exactly match a Redirect URI configured in your Spotify app settings. +# OAuth redirect registered in the Spotify Developer Dashboard (Authorization Code +# flow). Spotify sends the browser here with ?code=&state= — not to the Android app scheme. REDIRECT_URI = os.environ.get( "SPOTIFY_REDIRECT_URI", #"https://api.lockstep.at/spotify/callback" "https://api.lockstep.at/login/spotify/" ) +# After server-side token exchange, the browser may be redirected to this URL with +# tokens in the query string (mobile / Custom Tab). This is NOT registered with Spotify; +# only REDIRECT_URI above goes in the dashboard for this flow. +ALLOWED_SPOTIFY_APP_POST_LOGIN_REDIRECT = os.environ.get( + "SPOTIFY_APP_POST_LOGIN_REDIRECT_URI", + "at.lockstep.player://spotify/callback", +) + DB_PATH = os.environ.get("TOKEN_DB_PATH", "spotify_tokens.db") app = Flask(__name__) @@ -391,6 +401,28 @@ def index(): @app.route("/login//", methods=["GET", "POST"]) def login(provider_name): + # Authomatic 1.3.0 (oauth2.login) only runs "phase 1" — redirect to Spotify — + # when there are no query parameters, or only ``user_state``: + # elif (not self.params or (len(self.params) == 1 and 'user_state' in self.params)) + # A mobile client opening ``/login/spotify/?redirect_uri=...`` therefore matches + # no branch; login() returns without calling redirect() → empty body and HTTP 200 + # (white page). Stash the app callback in the session and reload without query args. + if ( + provider_name == "spotify" + and request.args.get("redirect_uri") + and "code" not in request.args + and "error" not in request.args + ): + requested = request.args.get("redirect_uri", "") + if requested != ALLOWED_SPOTIFY_APP_POST_LOGIN_REDIRECT: + return jsonify({ + "ok": False, + "error": "redirect_uri not allowed for this client", + }), 400 + session["spotify_oauth_app_redirect_uri"] = requested + session.modified = True + return redirect(url_for("login", provider_name=provider_name), code=302) + response = make_response() # Let Authomatic handle the OAuth2 handshake. @@ -398,7 +430,7 @@ def login(provider_name): WerkzeugAdapter(request, response), provider_name, session=session, - session_saver=lambda: session.modified + session_saver=lambda: setattr(session, "modified", True), ) # If result is None, Authomatic is still redirecting/processing. @@ -469,6 +501,27 @@ def login(provider_name): # keep the Spotify user id in session session["spotify_user_id"] = result.user.id + app_redirect = session.pop("spotify_oauth_app_redirect_uri", None) + if app_redirect: + if app_redirect != ALLOWED_SPOTIFY_APP_POST_LOGIN_REDIRECT: + return jsonify({ + "ok": False, + "error": "Stored app redirect_uri does not match allowlist", + }), 400 + sep = "&" if ("?" in app_redirect) else "?" + target = ( + f"{app_redirect}{sep}" + + urlencode( + { + "access_token": access_token, + "refresh_token": refresh_token, + "expires_in": str(expires_in), + "token_type": token_type or "", + }, + ) + ) + return redirect(target, code=302) + return jsonify({ "ok": True, "spotify_user_id": result.user.id,