From edaa55ae828fcb176b8fc466ba9239a2cd1f6f67 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:19:32 -0700 Subject: [PATCH] Harden Spotify OAuth callback for Docker/SSH tunnel setups (#220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-level try/except in do_GET ensures an HTTP response is always sent — previously, unhandled exceptions caused BaseHTTPRequestHandler to silently close the connection (ERR_EMPTY_RESPONSE). All callback logging now uses the app logger instead of print() so output appears in app.log rather than only Docker stdout. Added health check at / to verify the callback server is running, and startup now logs the actual bind address to help diagnose port conflicts. --- web_server.py | 212 ++++++++++++++++++++++++----------------- webui/static/helper.js | 1 + 2 files changed, 127 insertions(+), 86 deletions(-) diff --git a/web_server.py b/web_server.py index e72f407..1d621f7 100644 --- a/web_server.py +++ b/web_server.py @@ -19136,6 +19136,17 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "🔧 Fix Spotify OAuth ERR_EMPTY_RESPONSE in Docker (#220)", + "description": "OAuth callback server hardened for Docker/SSH tunnel setups", + "features": [ + "• Top-level error handler ensures an HTTP response is always sent (no more ERR_EMPTY_RESPONSE)", + "• All callback logging now goes to app.log (was only in Docker stdout before)", + "• Health check at http://localhost:8888/ to verify the callback server is running", + "• Startup logs the actual bind address for diagnosing port conflicts", + "• Port-in-use errors now logged clearly with explanation" + ] + }, { "title": "📊 Show All Services on Dashboard (#219)", "description": "Dashboard now shows connection status for all external services, not just the core three", @@ -42369,111 +42380,140 @@ def start_oauth_callback_servers(): import urllib.parse # Spotify callback server (port 8888 — for direct/local access only) + _oauth_logger = get_logger("oauth_callback") + class SpotifyCallbackHandler(BaseHTTPRequestHandler): def do_GET(self): - parsed_url = urllib.parse.urlparse(self.path) - - # Only process requests to /callback — ignore everything else - if parsed_url.path != '/callback': - self.send_response(404) - self.send_header('Content-type', 'text/plain') - self.end_headers() - self.wfile.write(b'Not found. Spotify callback is at /callback') - return + try: + parsed_url = urllib.parse.urlparse(self.path) - query_params = urllib.parse.parse_qs(parsed_url.query) - print(f"🎵 Spotify callback received on port 8888: {self.path}") + # Health check at root — lets users verify the server is running + if parsed_url.path == '/': + self.send_response(200) + self.send_header('Content-type', 'text/plain') + self.end_headers() + self.wfile.write(b'SoulSync Spotify OAuth callback server is running. Callback URL: /callback') + return - if 'code' in query_params: - auth_code = query_params['code'][0] - print(f"🎵 Received Spotify authorization code: {auth_code[:10]}...") + # Only process requests to /callback — ignore everything else + if parsed_url.path != '/callback': + self.send_response(404) + self.send_header('Content-type', 'text/plain') + self.end_headers() + self.wfile.write(b'Not found. Spotify callback is at /callback') + return - # Manually trigger the token exchange using spotipy's auth manager - try: - from core.spotify_client import SpotifyClient - from spotipy.oauth2 import SpotifyOAuth - from config.settings import config_manager + query_params = urllib.parse.parse_qs(parsed_url.query) + _oauth_logger.info(f"Spotify callback received on port 8888: {self.path}") - # Get Spotify config - config = config_manager.get_spotify_config() - configured_uri = config.get('redirect_uri', "http://127.0.0.1:8888/callback") - print(f"🎵 Using redirect_uri for token exchange: {configured_uri}") + if 'code' in query_params: + auth_code = query_params['code'][0] + _oauth_logger.info(f"Received Spotify authorization code: {auth_code[:10]}...") - # Create auth manager and exchange code for token - auth_manager = SpotifyOAuth( - client_id=config['client_id'], - client_secret=config['client_secret'], - redirect_uri=configured_uri, - scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", - cache_path='config/.spotify_cache' - ) + # Manually trigger the token exchange using spotipy's auth manager + try: + from core.spotify_client import SpotifyClient + from spotipy.oauth2 import SpotifyOAuth + from config.settings import config_manager + + # Get Spotify config + config = config_manager.get_spotify_config() + configured_uri = config.get('redirect_uri', "http://127.0.0.1:8888/callback") + _oauth_logger.info(f"Using redirect_uri for token exchange: {configured_uri}") + + # Create auth manager and exchange code for token + auth_manager = SpotifyOAuth( + client_id=config['client_id'], + client_secret=config['client_secret'], + redirect_uri=configured_uri, + scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", + cache_path='config/.spotify_cache' + ) - # Extract the authorization code and exchange it for tokens - token_info = auth_manager.get_access_token(auth_code, as_dict=True) - - if token_info: - # Reinitialize the global client with new tokens - global spotify_client - spotify_client = SpotifyClient() - - if spotify_client.is_authenticated(): - # Invalidate status cache so next poll picks up the new connection - _status_cache_timestamps['spotify'] = 0 - # Refresh enrichment worker's client so it picks up new auth - if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'): - spotify_enrichment_worker.client.reload_config() - add_activity_item("✅", "Spotify Auth Complete", "Successfully authenticated with Spotify", "Now") - self.send_response(200) - self.send_header('Content-type', 'text/html') - self.end_headers() - self.wfile.write(b'
You can close this window.
') + # Extract the authorization code and exchange it for tokens + token_info = auth_manager.get_access_token(auth_code, as_dict=True) + + if token_info: + # Reinitialize the global client with new tokens + global spotify_client + spotify_client = SpotifyClient() + + if spotify_client.is_authenticated(): + # Invalidate status cache so next poll picks up the new connection + _status_cache_timestamps['spotify'] = 0 + # Refresh enrichment worker's client so it picks up new auth + if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'): + spotify_enrichment_worker.client.reload_config() + add_activity_item("✅", "Spotify Auth Complete", "Successfully authenticated with Spotify", "Now") + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(b'You can close this window.
') + else: + raise Exception("Token exchange succeeded but authentication validation failed") else: - raise Exception("Token exchange succeeded but authentication validation failed") - else: - raise Exception("Failed to exchange authorization code for access token") - except Exception as e: - print(f"🔴 Spotify token processing error: {e}") - add_activity_item("❌", "Spotify Auth Failed", f"Token processing failed: {str(e)}", "Now") + raise Exception("Failed to exchange authorization code for access token") + except Exception as e: + _oauth_logger.error(f"Spotify token processing error: {e}") + add_activity_item("❌", "Spotify Auth Failed", f"Token processing failed: {str(e)}", "Now") + self.send_response(400) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(f'{str(e)}
'.encode()) + elif 'error' in query_params: + error = query_params['error'][0] + _oauth_logger.error(f"Spotify OAuth error returned by Spotify: {error}") + _oauth_logger.error(f"Full callback URL: {self.path}") + add_activity_item("❌", "Spotify Auth Failed", f"Spotify returned error: {error}", "Now") self.send_response(400) self.send_header('Content-type', 'text/html') self.end_headers() - self.wfile.write(f'{str(e)}
'.encode()) - elif 'error' in query_params: - error = query_params['error'][0] - print(f"🔴 Spotify OAuth error returned by Spotify: {error}") - print(f"🔴 Full callback URL: {self.path}") - add_activity_item("❌", "Spotify Auth Failed", f"Spotify returned error: {error}", "Now") - self.send_response(400) - self.send_header('Content-type', 'text/html') - self.end_headers() - self.wfile.write(f'Spotify returned error: {error}
'.encode()) - else: - # No code AND no error — callback was hit without OAuth params - print(f"🔴 Spotify callback received without OAuth parameters (no code or error)") - print(f"🔴 Path: {self.path} | Query params: {query_params}") - print(f"🔴 This usually means the redirect lost its query parameters (reverse proxy issue)") - self.send_response(400) - self.send_header('Content-type', 'text/html') - self.end_headers() - msg = ( - 'The callback was received but no authorization code or error was included.
' - 'If you are using a reverse proxy: Your proxy may be stripping query parameters '
- 'during the redirect. Try setting your Spotify redirect URI to use port 8008 instead '
- '(e.g. https://yourdomain.com/callback) — the main app handles callbacks too.
Spotify returned error: {error}
'.encode()) + else: + # No code AND no error — callback was hit without OAuth params + _oauth_logger.error(f"Spotify callback received without OAuth parameters (no code or error)") + _oauth_logger.error(f"Path: {self.path} | Query params: {query_params}") + _oauth_logger.error(f"This usually means the redirect lost its query parameters (reverse proxy issue)") + self.send_response(400) + self.send_header('Content-type', 'text/html') + self.end_headers() + msg = ( + 'The callback was received but no authorization code or error was included.
' + 'If you are using a reverse proxy: Your proxy may be stripping query parameters '
+ 'during the redirect. Try setting your Spotify redirect URI to use port 8008 instead '
+ '(e.g. https://yourdomain.com/callback) — the main app handles callbacks too.
{str(e)}
'.encode()) + except Exception: + pass # Connection already broken, nothing more we can do + def log_message(self, format, *args): - pass # Suppress server logs + pass # Suppress BaseHTTPRequestHandler access logs (we use our own logger) # Start Spotify callback server def run_spotify_server(): try: - spotify_server = HTTPServer(('0.0.0.0', 8888), SpotifyCallbackHandler) - print("🎵 Started Spotify OAuth callback server on port 8888") + bind_addr = ('0.0.0.0', 8888) + spotify_server = HTTPServer(bind_addr, SpotifyCallbackHandler) + _oauth_logger.info(f"Spotify OAuth callback server listening on {bind_addr[0]}:{bind_addr[1]}") + print(f"🎵 Started Spotify OAuth callback server on {bind_addr[0]}:{bind_addr[1]}") spotify_server.serve_forever() + except OSError as e: + _oauth_logger.error(f"Failed to start Spotify callback server on port 8888: {e} — port may already be in use") + print(f"🔴 Failed to start Spotify callback server on port 8888: {e}") except Exception as e: + _oauth_logger.error(f"Failed to start Spotify callback server: {e}") print(f"🔴 Failed to start Spotify callback server: {e}") # Tidal callback server diff --git a/webui/static/helper.js b/webui/static/helper.js index 34919c6..649dd96 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3403,6 +3403,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.1': [ // Newest features first + { title: 'Fix Spotify OAuth Empty Response', desc: 'OAuth callback server now always sends a response in Docker — added health check and proper logging' }, { title: 'All Services on Dashboard', desc: 'Dashboard shows all enrichment services as live-status chips — click unconfigured ones to jump to Settings. Spotify card no longer shows "Apple Music"', page: 'dashboard' }, { title: 'Qobuz on Connections Tab', desc: 'Qobuz credentials now on Settings → Connections for metadata enrichment without needing it as download source' }, { title: 'Fix Enrichment Status Widget', desc: 'Enrichment tooltip now shows Rate Limited or Daily Limit instead of stuck on Running' },