Reverse proxy fix

This commit is contained in:
Broque Thomas 2026-03-03 12:04:53 -08:00
parent d9aa8303a7
commit 65bae58cfe
2 changed files with 213 additions and 91 deletions

View file

@ -49,16 +49,66 @@ If you can access SoulSync directly from the Docker host machine:
- Set OAuth redirect URIs to localhost (as above) - Set OAuth redirect URIs to localhost (as above)
- No SSH tunnel needed - No SSH tunnel needed
## 🔧 For Advanced Users: Reverse Proxy ## 🔧 Reverse Proxy Setup (Caddy, Nginx, Traefik)
Set up nginx/traefik with proper SSL certificates for true HTTPS support. See community guides for Docker reverse proxy setups. If you're running SoulSync behind a reverse proxy with HTTPS, you can use the **main app port (8008)** for OAuth callbacks instead of the standalone port 8888. This is the recommended approach for reverse proxy setups.
### Step 1: Set your redirect URI to your proxy URL
**In SoulSync Settings:**
- Set Spotify redirect URI to: `https://yourdomain.com/callback`
**In your Spotify Developer Dashboard:**
- Add the same redirect URI: `https://yourdomain.com/callback`
### Step 2: Ensure your reverse proxy forwards to port 8008
Your reverse proxy should forward traffic to SoulSync's main port (8008). The `/callback` path is handled by the main Flask app — no need to expose port 8888.
**Example Caddy config:**
```
soulsync.yourdomain.com {
reverse_proxy localhost:8008
}
```
**Example Nginx config:**
```nginx
server {
listen 443 ssl;
server_name soulsync.yourdomain.com;
location / {
proxy_pass http://localhost:8008;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Step 3: Authenticate normally
Click "Connect Spotify" in SoulSync settings. After authorizing on Spotify, you'll be redirected back through your reverse proxy automatically.
### Important notes for reverse proxy users
- The redirect URI **must use HTTPS** for non-localhost domains (Spotify requirement)
- The redirect URI in SoulSync settings **must exactly match** the one in your Spotify Dashboard
- Port 8888 is only needed for direct/local access — you do **not** need to expose it through your proxy
- Make sure your proxy passes query parameters through unmodified (most do by default)
## 📝 Summary ## 📝 Summary
The core issue is that **Spotify requires HTTPS for non-localhost** OAuth redirects. The SSH tunnel makes remote devices appear as localhost to bypass this requirement. The core issue is that **Spotify requires HTTPS for non-localhost** OAuth redirects.
**Choose your approach:**
- **Reverse proxy with HTTPS**: Set redirect URI to `https://yourdomain.com/callback` (recommended for production)
- **SSH tunnel**: Makes remote devices appear as localhost — set redirect URI to `http://127.0.0.1:8888/callback`
- **Local access**: No special config needed — default `http://127.0.0.1:8888/callback` works
**Key points:** **Key points:**
- ✅ Always use `127.0.0.1` in OAuth redirect URIs - ✅ Reverse proxy users: use `https://yourdomain.com/callback` on port 8008
- ✅ Use SSH tunnel when accessing from different device - ✅ SSH tunnel users: use `http://127.0.0.1:8888/callback` on port 8888
- ✅ Keep tunnel open during authentication - ✅ Redirect URI must match exactly in SoulSync settings AND Spotify Dashboard
- ✅ Works with existing Docker setup - no changes needed - ✅ Query parameters must be preserved through the redirect chain

View file

@ -3334,6 +3334,8 @@ def auth_spotify():
if temp_spotify_client.sp and temp_spotify_client.sp.auth_manager: if temp_spotify_client.sp and temp_spotify_client.sp.auth_manager:
# Get the authorization URL # Get the authorization URL
auth_url = temp_spotify_client.sp.auth_manager.get_authorize_url() auth_url = temp_spotify_client.sp.auth_manager.get_authorize_url()
configured_uri = config_manager.get_spotify_config().get('redirect_uri', 'http://127.0.0.1:8888/callback')
print(f"🎵 Spotify auth initiated — redirect_uri: {configured_uri}")
add_activity_item("🔐", "Spotify Auth Started", "Please complete OAuth in browser", "Now") add_activity_item("🔐", "Spotify Auth Started", "Please complete OAuth in browser", "Now")
# Detect if accessing remotely # Detect if accessing remotely
@ -3345,61 +3347,98 @@ def auth_spotify():
if is_docker and host == '127.0.0.1': if is_docker and host == '127.0.0.1':
host = 'localhost' host = 'localhost'
# Check if the redirect_uri uses port 8008 (main app) vs 8888 (standalone)
uses_main_port = ':8008' in configured_uri or ':8888' not in configured_uri
if is_remote or is_docker: if is_remote or is_docker:
# Show instructions for remote/docker access # Show instructions for remote/docker access
page_title = "🔐 Spotify Authentication (Remote/Docker)" if uses_main_port:
step_1_text = "Click the link below to authenticate with Spotify" # redirect_uri already points to port 8008 or a custom domain —
# callback will come through the main Flask app, no manual steps needed
return f'''
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; }}
code {{ background: #f0f0f0; padding: 4px 8px; border-radius: 3px; }}
.info {{ background: #e3f2fd; border-left: 4px solid #2196F3; padding: 12px 16px; margin: 16px 0; border-radius: 4px; }}
</style>
</head>
<body>
<h1>🔐 Spotify Authentication</h1>
<p>Click the link below to authenticate with Spotify:</p>
<p><a href="{auth_url}" target="_blank" style="font-size: 18px; color: #1DB954;">Authenticate with Spotify</a></p>
<div class="info">
<strong>Redirect URI:</strong> <code>{configured_uri}</code><br>
<small>After authorizing, Spotify will redirect back automatically. Make sure this URL matches your Spotify Dashboard redirect URI.</small>
</div>
<p>After authentication completes, you can close this window and return to SoulSync.</p>
</body>
</html>
'''
else:
# redirect_uri still points to port 8888 — show manual steps AND suggest switching
return f'''
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; }}
code {{ background: #f0f0f0; padding: 10px; display: block; margin: 10px 0; }}
.highlight {{ background: #e8f5e9; }}
.warning {{ background: #fff3e0; border-left: 4px solid #ff9800; padding: 12px 16px; margin: 16px 0; border-radius: 4px; }}
.copy-btn {{
background: #1DB954;
color: white;
border: none;
padding: 8px 16px;
cursor: pointer;
border-radius: 4px;
font-size: 14px;
margin-left: 10px;
}}
.copy-btn:hover {{ background: #1ed760; }}
.copied {{ background: #4CAF50 !important; }}
</style>
</head>
<body>
<h1>🔐 Spotify Authentication (Remote/Docker)</h1>
return f''' <div class="warning">
<html> <strong>Using a reverse proxy?</strong> Your redirect URI is set to <code style="display:inline; padding: 2px 6px;">{configured_uri}</code>
<head> which uses port 8888. If you're behind a reverse proxy (Caddy, Nginx, Traefik), change the
<style> redirect URI in SoulSync settings to use your proxy URL on the main port instead, e.g.:<br>
body {{ font-family: Arial, sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; }} <code style="display:inline; padding: 2px 6px; background: #e8f5e9;">https://{host}/callback</code><br>
code {{ background: #f0f0f0; padding: 10px; display: block; margin: 10px 0; }} Then update the same URI in your <a href="https://developer.spotify.com/dashboard" target="_blank">Spotify Dashboard</a>.
.highlight {{ background: #e8f5e9; }} This avoids the need for manual URL editing below.
.copy-btn {{ </div>
background: #1DB954;
color: white;
border: none;
padding: 8px 16px;
cursor: pointer;
border-radius: 4px;
font-size: 14px;
margin-left: 10px;
}}
.copy-btn:hover {{ background: #1ed760; }}
.copied {{ background: #4CAF50 !important; }}
</style>
</head>
<body>
<h1>{page_title}</h1>
<p><strong>Step 1:</strong> {step_1_text}</p>
<p><a href="{auth_url}" target="_blank" style="font-size: 18px; color: #1DB954;">{auth_url}</a></p>
<hr>
<p><strong>Step 2:</strong> After authorizing, you'll see a blank page. The URL will look like:</p>
<code>http://127.0.0.1:8888/callback?code=...</code>
<p><strong>Step 3:</strong> Change <code style="display: inline; background: #ffe6e6; padding: 2px 6px;">127.0.0.1</code> to <code style="display: inline; background: #e8f5e9; padding: 2px 6px;">{host}</code> and press Enter:
<button class="copy-btn" onclick="copyIP()">Copy IP</button>
</p>
<code class="highlight">http://{host}:8888/callback?code=...</code>
<p>Authentication will then complete!</p>
<script> <p><strong>Step 1:</strong> Click the link below to authenticate with Spotify</p>
function copyIP() {{ <p><a href="{auth_url}" target="_blank" style="font-size: 18px; color: #1DB954;">{auth_url}</a></p>
navigator.clipboard.writeText('{host}').then(() => {{ <hr>
const btn = event.target; <p><strong>Step 2:</strong> After authorizing, you'll see a blank page. The URL will look like:</p>
btn.textContent = '✓ Copied!'; <code>http://127.0.0.1:8888/callback?code=...</code>
btn.classList.add('copied'); <p><strong>Step 3:</strong> Change <code style="display: inline; background: #ffe6e6; padding: 2px 6px;">127.0.0.1</code> to <code style="display: inline; background: #e8f5e9; padding: 2px 6px;">{host}</code> and press Enter:
setTimeout(() => {{ <button class="copy-btn" onclick="copyIP()">Copy IP</button>
btn.textContent = 'Copy IP'; </p>
btn.classList.remove('copied'); <code class="highlight">http://{host}:8888/callback?code=...</code>
}}, 2000); <p>Authentication will then complete!</p>
}});
}} <script>
</script> function copyIP() {{
</body> navigator.clipboard.writeText('{host}').then(() => {{
</html> const btn = event.target;
''' btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(() => {{
btn.textContent = 'Copy IP';
btn.classList.remove('copied');
}}, 2000);
}});
}}
</script>
</body>
</html>
'''
else: else:
# Local access - simple message # Local access - simple message
return f'<h1>🔐 Spotify Authentication</h1><p>Click the link below to authenticate:</p><p><a href="{auth_url}" target="_blank">{auth_url}</a></p><p>After authentication, return to the app.</p>' return f'<h1>🔐 Spotify Authentication</h1><p>Click the link below to authenticate:</p><p><a href="{auth_url}" target="_blank">{auth_url}</a></p><p>After authentication, return to the app.</p>'
@ -3530,18 +3569,28 @@ def auth_tidal():
def spotify_callback(): def spotify_callback():
""" """
Handles Spotify OAuth callback via the main Flask app (port 8008). Handles Spotify OAuth callback via the main Flask app (port 8008).
This allows reverse proxy users to use a redirect_uri pointing at the main app. This is the recommended callback for reverse proxy / Docker setups.
The dedicated HTTPServer on port 8888 continues to work for direct access. The dedicated HTTPServer on port 8888 continues to work for direct/local access.
""" """
global spotify_client global spotify_client
auth_code = request.args.get('code') auth_code = request.args.get('code')
if not auth_code: if not auth_code:
error = request.args.get('error', 'Unknown error') error = request.args.get('error')
if 'error' not in request.args: if error:
# Spurious request (e.g., healthcheck) - ignore silently print(f"🔴 Spotify OAuth error on port 8008: Spotify returned error: {error}")
return '', 204 add_activity_item("", "Spotify Auth Failed", f"Spotify returned error: {error}", "Now")
return f"<h1>Spotify Authentication Failed</h1><p>OAuth error: {error}</p>", 400 return f"<h1>Spotify Authentication Failed</h1><p>Spotify returned error: {error}</p>", 400
# No code AND no error — check if query params were stripped
if request.args:
print(f"🔴 Spotify callback on port 8008 received unexpected params: {dict(request.args)}")
else:
# Completely empty — likely a healthcheck or spurious request
pass
return '', 204
print(f"🎵 Spotify callback received on port 8008 with authorization code")
try: try:
from core.spotify_client import SpotifyClient from core.spotify_client import SpotifyClient
@ -3549,10 +3598,13 @@ def spotify_callback():
from config.settings import config_manager from config.settings import config_manager
config = config_manager.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}")
auth_manager = SpotifyOAuth( auth_manager = SpotifyOAuth(
client_id=config['client_id'], client_id=config['client_id'],
client_secret=config['client_secret'], client_secret=config['client_secret'],
redirect_uri=config.get('redirect_uri', "http://127.0.0.1:8888/callback"), redirect_uri=configured_uri,
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email",
cache_path='config/.spotify_cache' cache_path='config/.spotify_cache'
) )
@ -3571,7 +3623,7 @@ def spotify_callback():
else: else:
raise Exception("Failed to exchange authorization code for access token") raise Exception("Failed to exchange authorization code for access token")
except Exception as e: except Exception as e:
print(f"🔴 Spotify OAuth callback error: {e}") print(f"🔴 Spotify OAuth callback error on port 8008: {e}")
add_activity_item("", "Spotify Auth Failed", f"Token processing failed: {str(e)}", "Now") add_activity_item("", "Spotify Auth Failed", f"Token processing failed: {str(e)}", "Now")
return f"<h1>Spotify Authentication Failed</h1><p>{str(e)}</p>", 400 return f"<h1>Spotify Authentication Failed</h1><p>{str(e)}</p>", 400
@ -27806,12 +27858,21 @@ def start_oauth_callback_servers():
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.parse import urllib.parse
# Spotify callback server # Spotify callback server (port 8888 — for direct/local access only)
class SpotifyCallbackHandler(BaseHTTPRequestHandler): class SpotifyCallbackHandler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
print(f"🎵 Spotify callback received: {self.path}")
parsed_url = urllib.parse.urlparse(self.path) 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
query_params = urllib.parse.parse_qs(parsed_url.query) query_params = urllib.parse.parse_qs(parsed_url.query)
print(f"🎵 Spotify callback received on port 8888: {self.path}")
if 'code' in query_params: if 'code' in query_params:
auth_code = query_params['code'][0] auth_code = query_params['code'][0]
@ -27825,12 +27886,14 @@ def start_oauth_callback_servers():
# Get Spotify config # Get Spotify config
config = config_manager.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}")
# Create auth manager and exchange code for token # Create auth manager and exchange code for token
auth_manager = SpotifyOAuth( auth_manager = SpotifyOAuth(
client_id=config['client_id'], client_id=config['client_id'],
client_secret=config['client_secret'], client_secret=config['client_secret'],
redirect_uri=config.get('redirect_uri', "http://127.0.0.1:8888/callback"), redirect_uri=configured_uri,
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email",
cache_path='config/.spotify_cache' cache_path='config/.spotify_cache'
) )
@ -27862,22 +27925,31 @@ def start_oauth_callback_servers():
self.send_header('Content-type', 'text/html') self.send_header('Content-type', 'text/html')
self.end_headers() self.end_headers()
self.wfile.write(f'<h1>Spotify Authentication Failed</h1><p>{str(e)}</p>'.encode()) self.wfile.write(f'<h1>Spotify Authentication Failed</h1><p>{str(e)}</p>'.encode())
else: elif 'error' in query_params:
error = query_params.get('error', ['Unknown error'])[0] error = query_params['error'][0]
print(f"🔴 Spotify OAuth error: {error}") print(f"🔴 Spotify OAuth error returned by Spotify: {error}")
print(f"🔴 Full Spotify callback URL: {self.path}") print(f"🔴 Full callback URL: {self.path}")
print(f"🔴 All query params: {query_params}") add_activity_item("", "Spotify Auth Failed", f"Spotify returned error: {error}", "Now")
# Only show error toast if it's not just a spurious request
if 'error' in query_params:
add_activity_item("", "Spotify Auth Failed", f"OAuth error: {error}", "Now")
else:
print("🔴 Spurious Spotify callback without code or error - ignoring")
self.send_response(400) self.send_response(400)
self.send_header('Content-type', 'text/html') self.send_header('Content-type', 'text/html')
self.end_headers() self.end_headers()
self.wfile.write(f'<h1>Spotify Authentication Failed</h1><p>{error}</p>'.encode()) self.wfile.write(f'<h1>Spotify Authentication Failed</h1><p>Spotify returned error: {error}</p>'.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 = (
'<h1>Spotify Authentication Failed</h1>'
'<p>The callback was received but no authorization code or error was included.</p>'
'<p><strong>If you are using a reverse proxy:</strong> Your proxy may be stripping query parameters '
'during the redirect. Try setting your Spotify redirect URI to use port 8008 instead '
'(e.g. <code>https://yourdomain.com/callback</code>) — the main app handles callbacks too.</p>'
)
self.wfile.write(msg.encode())
def log_message(self, format, *args): def log_message(self, format, *args):
pass # Suppress server logs pass # Suppress server logs