feat: implement HTTPS middleware and static OAuth URL configuration

This commit is contained in:
Christian Krakau-Louis
2025-06-09 15:32:58 +02:00
parent c6b89781c9
commit 9caf9ec791
6 changed files with 218 additions and 14 deletions
+20
View File
@@ -0,0 +1,20 @@
# Standard OAuth redirect URIs
SPOTIFY_REDIRECT_URI=https://qb.kaufdeinquiz.com/auth/spotify/callback
GOOGLE_REDIRECT_URI=https://qb.kaufdeinquiz.com/users/login/google/callback
AUTHENTIK_REDIRECT_URI=https://qb.kaufdeinquiz.com/users/login/authentik/callback
DROPBOX_REDIRECT_URI=https://qb.kaufdeinquiz.com/users/dropbox/callback
# OAuth settings
USE_HTTPS=True # Set to True when behind a reverse proxy with HTTPS offloading
PREFERRED_URL_SCHEME=https # Explicitly set the preferred URL scheme
# Static OAuth redirect URIs for production environments
# These override the dynamic URLs generated by url_for() when specified
STATIC_OAUTH_URLS=True # Enable static URLs
OAUTH_SPOTIFY_AUTH_URL=https://qb.kaufdeinquiz.com/auth/spotify/callback
OAUTH_SPOTIFY_LINK_URL=https://qb.kaufdeinquiz.com/users/spotify-link/callback
OAUTH_GOOGLE_URL=https://qb.kaufdeinquiz.com/users/login/google/callback
OAUTH_AUTHENTIK_URL=https://qb.kaufdeinquiz.com/users/login/authentik/callback
OAUTH_DROPBOX_URL=https://qb.kaufdeinquiz.com/users/dropbox/callback
# Important: Make sure these static URLs match your actual server URLs and OAuth provider configurations
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
This script helps debug OAuth URL generation when behind a reverse proxy.
It's especially useful for debugging HTTPS redirection issues.
Usage:
python debug_oauth_urls.py [--https] [--host hostname] [--port portnumber]
Options:
--https Force HTTPS URL generation regardless of request headers
--host Set the hostname (default: localhost)
--port Set the port number (default: 5000)
"""
import sys
import os
import argparse
from flask import Flask, request, jsonify
from dotenv import load_dotenv
# Make sure we can import from the musicround package
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Parse command line arguments
parser = argparse.ArgumentParser(description="Debug OAuth URL generation")
parser.add_argument("--https", action="store_true",
help="Force HTTPS URL generation")
parser.add_argument("--host", default="localhost",
help="Set the hostname (default: localhost)")
parser.add_argument("--port", type=int, default=5000,
help="Set the port number (default: 5000)")
args = parser.parse_args()
# Load environment variables
load_dotenv()
# If .env.oauth exists, load it too (it has OAuth specific settings)
if os.path.exists(".env.oauth"):
load_dotenv(".env.oauth")
# If .env.oauth.production exists and --https is specified, load that instead
if args.https and os.path.exists(".env.oauth.production"):
load_dotenv(".env.oauth.production", override=True)
print("Loading production OAuth settings from .env.oauth.production")
# Create a small Flask app just for debugging
app = Flask(__name__)
# Override config with command line args
app.config["USE_HTTPS"] = args.https
app.config["PREFERRED_URL_SCHEME"] = "https" if args.https else "http"
@app.route("/")
def debug_oauth():
"""Generate debug information for OAuth URLs"""
from musicround.helpers.auth_helpers import get_oauth_redirect_uri
# Generate endpoints to test
endpoints = [
("auth.callback", None),
("users.spotify_link_callback", None),
("users.google_callback", None),
("users.authentik_callback", None),
("users.dropbox_callback", None)
]
# Generate test URLs
test_urls = {}
for endpoint, provider in endpoints:
try:
# We need to be in an app context to use url_for
with app.app_context():
# Allow KeyErrors to propagate so we know which endpoints don't exist
url = get_oauth_redirect_uri(endpoint, provider)
test_urls[endpoint] = url
except Exception as e:
test_urls[endpoint] = f"ERROR: {str(e)}"
# Get request info
headers = {
key: value for key, value in request.headers.items()
if key.lower() in ('x-forwarded-for', 'x-forwarded-proto',
'x-forwarded-host', 'host', 'origin', 'referer')
}
# Return detailed info
return jsonify({
"test_urls": test_urls,
"config": {
"USE_HTTPS": app.config.get("USE_HTTPS", False),
"PREFERRED_URL_SCHEME": app.config.get("PREFERRED_URL_SCHEME", "http"),
"STATIC_OAUTH_URLS": app.config.get("STATIC_OAUTH_URLS", False),
"args": {
"https": args.https,
"host": args.host,
"port": args.port
},
},
"headers": headers,
"request_info": {
"url": request.url,
"base_url": request.base_url,
"host": request.host,
"scheme": request.scheme,
}
})
if __name__ == "__main__":
print(f"Starting OAuth URL Debug server on http://{args.host}:{args.port}")
print(f"USE_HTTPS is set to: {app.config['USE_HTTPS']}")
print(f"PREFERRED_URL_SCHEME is set to: {app.config['PREFERRED_URL_SCHEME']}")
print(f"Visit http://{args.host}:{args.port}/ to see debug information")
app.run(host=args.host, port=args.port, debug=True)
+17 -2
View File
@@ -100,12 +100,24 @@ def create_app(config=None):
# Configure ProxyFix for reverse proxy (e.g., Nginx) # Configure ProxyFix for reverse proxy (e.g., Nginx)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
# Create custom HTTPS middleware (will be applied below if USE_HTTPS is True)
class ForceHTTPSMiddleware:
"""Middleware to force HTTPS scheme regardless of request headers"""
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
# Force the wsgi.url_scheme to be https
environ['wsgi.url_scheme'] = 'https'
# Also override X-Forwarded-Proto if present
environ['HTTP_X_FORWARDED_PROTO'] = 'https'
return self.app(environ, start_response)
# Create data directory if it doesn't exist # Create data directory if it doesn't exist
data_dir = '/data' data_dir = '/data'
if not os.path.exists(data_dir): if not os.path.exists(data_dir):
os.makedirs(data_dir, exist_ok=True) os.makedirs(data_dir, exist_ok=True)
# Set the database file path in the data directory
# Set the database file path in the data directory
db_path = os.path.join(data_dir, 'song_data.db') db_path = os.path.join(data_dir, 'song_data.db')
# Configure the app # Configure the app
@@ -114,6 +126,9 @@ def create_app(config=None):
# Set preferred URL scheme for reverse proxy support # Set preferred URL scheme for reverse proxy support
if app.config.get('USE_HTTPS'): if app.config.get('USE_HTTPS'):
app.config['PREFERRED_URL_SCHEME'] = 'https' app.config['PREFERRED_URL_SCHEME'] = 'https'
# Apply the HTTPS middleware when USE_HTTPS is True
app.wsgi_app = ForceHTTPSMiddleware(app.wsgi_app)
app.logger.info("Applying ForceHTTPSMiddleware - all URLs will use HTTPS scheme regardless of headers")
# Explicitly set the database URI to ensure correct path # Explicitly set the database URI to ensure correct path
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}' app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
+10 -4
View File
@@ -69,11 +69,17 @@ class Config:
MAIL_RECIPIENT = os.getenv("MAIL_RECIPIENT", "admin@example.com") MAIL_RECIPIENT = os.getenv("MAIL_RECIPIENT", "admin@example.com")
# Automation settings # Automation settings
AUTOMATION_TOKEN = os.getenv("AUTOMATION_TOKEN", "change-this-token-in-production") AUTOMATION_TOKEN = os.getenv("AUTOMATION_TOKEN", "change-this-token-in-production") # Reverse proxy settings
# Reverse proxy settings
USE_HTTPS = os.getenv("USE_HTTPS", "False") == "True" # Force HTTPS URL generation USE_HTTPS = os.getenv("USE_HTTPS", "False") == "True" # Force HTTPS URL generation
PREFERRED_URL_SCHEME = 'https' if USE_HTTPS else 'http' PREFERRED_URL_SCHEME = os.getenv("PREFERRED_URL_SCHEME", 'https' if USE_HTTPS else 'http')
# Static OAuth URL configuration (for production environments)
STATIC_OAUTH_URLS = os.getenv("STATIC_OAUTH_URLS", "False") == "True"
OAUTH_SPOTIFY_AUTH_URL = os.getenv("OAUTH_SPOTIFY_AUTH_URL")
OAUTH_SPOTIFY_LINK_URL = os.getenv("OAUTH_SPOTIFY_LINK_URL")
OAUTH_GOOGLE_URL = os.getenv("OAUTH_GOOGLE_URL")
OAUTH_AUTHENTIK_URL = os.getenv("OAUTH_AUTHENTIK_URL")
OAUTH_DROPBOX_URL = os.getenv("OAUTH_DROPBOX_URL")
+46 -6
View File
@@ -349,22 +349,62 @@ def update_oauth_tokens(user, tokens, auth_provider):
def get_oauth_redirect_uri(endpoint, provider=None): def get_oauth_redirect_uri(endpoint, provider=None):
""" """
Generate OAuth redirect URI with proper scheme handling for reverse proxy environments Generate OAuth redirect URI with proper scheme handling for reverse proxy environments
This function chooses the redirect URI using the following priority:
1. Static URL from config (if STATIC_OAUTH_URLS is True)
2. Dynamic URL generated by url_for() with PREFERRED_URL_SCHEME
3. Force to HTTPS if USE_HTTPS=True regardless of incoming request
""" """
# Use Flask's url_for which respects PREFERRED_URL_SCHEME # Check if static OAuth URLs are enabled
if provider: use_static_urls = current_app.config.get('STATIC_OAUTH_URLS', False)
redirect_uri = url_for(endpoint, provider=provider, _external=True) use_https = current_app.config.get('USE_HTTPS', False)
else:
redirect_uri = url_for(endpoint, _external=True) # Define mapping from endpoint to config key for static URLs
static_url_mapping = {
'auth.callback': 'OAUTH_SPOTIFY_AUTH_URL',
'users.spotify_link_callback': 'OAUTH_SPOTIFY_LINK_URL',
'users.google_callback': 'OAUTH_GOOGLE_URL',
'users.authentik_callback': 'OAUTH_AUTHENTIK_URL',
'users.dropbox_callback': 'OAUTH_DROPBOX_URL'
}
# First try to use a static URL if enabled and available
redirect_uri = None
if use_static_urls and endpoint in static_url_mapping:
config_key = static_url_mapping[endpoint]
redirect_uri = current_app.config.get(config_key)
if redirect_uri:
current_app.logger.debug(f"Using static OAuth URL for {endpoint}: {redirect_uri}")
else:
current_app.logger.warning(
f"Static OAuth URLs enabled but no URL defined for {endpoint} "
f"(expected config key: {config_key})"
)
# If no static URL, use Flask's url_for which respects PREFERRED_URL_SCHEME
if not redirect_uri:
if provider:
redirect_uri = url_for(endpoint, provider=provider, _external=True)
else:
redirect_uri = url_for(endpoint, _external=True)
# Force HTTPS when USE_HTTPS=True regardless of the generated URL scheme
if use_https and redirect_uri.startswith('http:'):
redirect_uri = 'https:' + redirect_uri[5:]
current_app.logger.info(f"Forcing HTTPS for OAuth redirect URI: {redirect_uri}")
# Log details about the generated URL for debugging # Log details about the generated URL for debugging
use_https = current_app.config.get('USE_HTTPS', False)
preferred_scheme = current_app.config.get('PREFERRED_URL_SCHEME', 'http') preferred_scheme = current_app.config.get('PREFERRED_URL_SCHEME', 'http')
static_enabled = "Yes" if use_static_urls else "No"
static_url_used = "Yes" if use_static_urls and redirect_uri and endpoint in static_url_mapping and current_app.config.get(static_url_mapping[endpoint]) else "No"
current_app.logger.debug( current_app.logger.debug(
f"OAuth Redirect URI: {redirect_uri} | " f"OAuth Redirect URI: {redirect_uri} | "
f"Endpoint: {endpoint} | " f"Endpoint: {endpoint} | "
f"USE_HTTPS: {use_https} | " f"USE_HTTPS: {use_https} | "
f"PREFERRED_URL_SCHEME: {preferred_scheme} | " f"PREFERRED_URL_SCHEME: {preferred_scheme} | "
f"Static URLs enabled: {static_enabled} | "
f"Used static URL: {static_url_used} | "
f"Request scheme: {request.scheme if request else 'N/A'} | " f"Request scheme: {request.scheme if request else 'N/A'} | "
f"X-Forwarded-Proto: {request.headers.get('X-Forwarded-Proto', 'N/A') if request else 'N/A'}" f"X-Forwarded-Proto: {request.headers.get('X-Forwarded-Proto', 'N/A') if request else 'N/A'}"
) )
+11 -2
View File
@@ -18,10 +18,19 @@ def debug_oauth_urls():
Formats: Formats:
- HTML: Default view with pretty UI - HTML: Default view with pretty UI
- JSON: Add ?format=json or use Accept: application/json header - JSON: Add ?format=json or use Accept: application/json header
""" """ # Get config settings
# Get config settings
use_https = current_app.config.get('USE_HTTPS', False) use_https = current_app.config.get('USE_HTTPS', False)
preferred_scheme = current_app.config.get('PREFERRED_URL_SCHEME', 'http') preferred_scheme = current_app.config.get('PREFERRED_URL_SCHEME', 'http')
static_oauth_urls = current_app.config.get('STATIC_OAUTH_URLS', False)
# Get all static URL configurations
static_urls = {
'OAUTH_SPOTIFY_AUTH_URL': current_app.config.get('OAUTH_SPOTIFY_AUTH_URL'),
'OAUTH_SPOTIFY_LINK_URL': current_app.config.get('OAUTH_SPOTIFY_LINK_URL'),
'OAUTH_GOOGLE_URL': current_app.config.get('OAUTH_GOOGLE_URL'),
'OAUTH_AUTHENTIK_URL': current_app.config.get('OAUTH_AUTHENTIK_URL'),
'OAUTH_DROPBOX_URL': current_app.config.get('OAUTH_DROPBOX_URL')
}
# Generate all OAuth callback URLs using the helper function # Generate all OAuth callback URLs using the helper function
oauth_urls = { oauth_urls = {