GitHub user bandebard added a comment to the discussion: How to implement both
OAuth2 authentication and traditional username/password login simultaneously?
Does this help?
# Implementing Unified OAuth + Database Login in Apache Superset
This guide documents how to implement a unified login page in Apache Superset
that supports both OAuth (e.g., Google) and traditional username/password
authentication on the same page. This is particularly useful when you need to
support both Google Account-based authentication and legacy users who don't
have Google accounts.
## Problem
By default, Superset's Flask-AppBuilder framework provides separate login
routes for OAuth and database authentication:
- `/login/` - Default login page (redirects to OAuth if configured)
- `/login/{provider}` - OAuth provider login (e.g., `/login/google`)
- `/login/db` - Database authentication (if `AUTH_TYPE = AUTH_OAUTH_DB`)
However, Superset's React frontend intercepts the `/login/` route, making it
difficult to customize the login page to show both options simultaneously.
Additionally, when `AUTH_TYPE = AUTH_OAUTH` is set, the database login option
disappears entirely.
## Solution Overview
The solution involves:
1. Creating a custom Flask route that bypasses Superset's React frontend
2. Rendering a custom HTML page with both OAuth and database login forms
3. Handling CSRF protection for the database login form
4. Properly redirecting users after successful authentication
## Implementation Steps
### 1. Extend SupersetSecurityManager
Create a custom security manager in your `superset_config.py`:
```python
from flask_appbuilder.security.sqla.manager import SecurityManager
from superset.security import SupersetSecurityManager
class CustomSecurityManager(SupersetSecurityManager):
"""
Custom security manager that extends Superset's default security manager.
Key features:
- Support for both OAuth and database authentication
"""
def auth_user_oauth(self, userinfo):
"""
Override OAuth user authentication.
This method is called after OAuth provider returns user information.
It looks up the user in the database and logs them in.
Args:
userinfo: Dictionary containing user information from OAuth provider
(e.g., {'email': '[email protected]', 'name': 'John Doe'})
Returns:
User object if found and active, None otherwise
"""
import logging
from flask_appbuilder.security.sqla.models import User
log = logging.getLogger(__name__)
email = userinfo.get("email", "").lower().strip()
if not email:
log.warning("OAuth userinfo missing email field")
return None
# Try to find user by email (case-insensitive)
user = (
self.appbuilder.session.query(User)
.filter(
func.lower(User.email) == email,
User.active == True
)
.first()
)
if user:
log.info(f"OAuth login successful for user: {user.username} (email:
{email})")
return user
# Fallback: try username if email lookup fails
username = userinfo.get("username", "").lower().strip()
if username:
user = (
self.appbuilder.session.query(User)
.filter(
func.lower(User.username) == username,
User.active == True
)
.first()
)
if user:
log.info(f"OAuth login successful for user: {user.username}
(via username fallback)")
return user
log.warning(f"OAuth user not found: email={email}, username={username}")
log.warning("User must be created by admin before OAuth login will
work")
return None
```
### 2. Configure OAuth and Authentication
In your `superset_config.py`, configure OAuth and set up the authentication
type:
```python
import os
from flask_appbuilder.security.manager import AUTH_OAUTH
# OAuth Configuration
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET:
# Enable OAuth authentication
AUTH_TYPE = AUTH_OAUTH
# OAuth providers configuration
OAUTH_PROVIDERS = [
{
"name": "google",
"icon": "fa-google",
"token_key": "access_token",
"remote_app": {
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"server_metadata_url":
"https://accounts.google.com/.well-known/openid-configuration",
"client_kwargs": {
"scope": "openid email profile",
},
"authorize_url": "https://accounts.google.com/o/oauth2/auth",
"access_token_url": "https://oauth2.googleapis.com/token",
},
}
]
# Map OAuth user info to Superset user fields
OAUTH_USER_INFO = {
"google": {
"email": "email",
"first_name": "given_name",
"last_name": "family_name",
"id": "sub",
"username": "email",
}
}
# Disable auto-user registration (admin must create users)
AUTH_USER_REGISTRATION = False
AUTH_USER_REGISTRATION_ROLE = "Public"
else:
# Fallback to database authentication if OAuth not configured
AUTH_TYPE = AUTH_DB
# Use custom security manager
CUSTOM_SECURITY_MANAGER = CustomSecurityManager
# HTTPS configuration for Cloud Run / reverse proxy
PREFERRED_URL_SCHEME = "https"
ENABLE_PROXY_FIX = True
```
### 3. Create Unified Login Route
Add a function to register the unified login route. This should be called after
the Flask app is created:
```python
def register_unified_login(app):
"""
Register a custom unified login route that shows both OAuth and Database
login.
This function creates a Flask route that bypasses Superset's React frontend
and serves a custom HTML page with both authentication options.
Args:
app: Flask application instance
"""
from flask import request, redirect, url_for, flash, Response
from flask_login import login_user
from flask_wtf.csrf import generate_csrf, validate_csrf, CSRFError
import html
import logging
log = logging.getLogger(__name__)
@app.route('/unified-login', methods=['GET', 'POST'])
def unified_login():
"""
Unified login page with both OAuth and Database authentication.
GET: Displays the login page with OAuth buttons and database login form
POST: Processes database login credentials
"""
# Handle POST (database login)
if request.method == 'POST':
try:
# Validate CSRF token
validate_csrf(request.form.get('csrf_token'))
except CSRFError as e:
log.error(f"CSRF Error during login: {e}", exc_info=True)
flash('Security error: Invalid CSRF token. Please try again.',
'error')
return redirect(url_for('unified_login',
next=request.args.get('next', '')))
try:
username = request.form.get('username')
password = request.form.get('password')
log.info(f"Login attempt for username: {username}")
if username and password:
# Get security manager from app
from flask import current_app
sm = current_app.appbuilder.sm
user = sm.auth_user_db(username, password)
if user:
log.info(f"Login successful for user: {username} (ID:
{user.id})")
login_user(user, remember=False)
next_url = request.args.get('next') or
'/superset/welcome/'
# Clean up next_url if it has nested redirects
if '?' in next_url:
next_url = next_url.split('?')[0]
log.info(f"Redirecting to: {next_url}")
return redirect(next_url)
else:
log.warning(f"Login failed: Invalid credentials for
username: {username}")
flash('Invalid username or password', 'error')
# Redirect back to show error message
return redirect(url_for('unified_login',
next=request.args.get('next', '')))
else:
log.warning("Login attempt with missing username or
password")
flash('Please enter both username and password', 'error')
# Redirect back to show error message
return redirect(url_for('unified_login',
next=request.args.get('next', '')))
except CSRFError as e:
log.error(f"CSRF Error during login: {e}", exc_info=True)
flash('Security error: Invalid CSRF token. Please try again.',
'error')
# Redirect back to show error message
return redirect(url_for('unified_login',
next=request.args.get('next', '')))
except Exception as e:
log.error(f"Login error: {e}", exc_info=True)
flash(f'Login error: {str(e)}', 'error')
# Redirect back to show error message
return redirect(url_for('unified_login',
next=request.args.get('next', '')))
# GET - show login page
next_url = request.args.get('next', '')
next_param = f'?next={html.escape(next_url)}' if next_url else ''
# Get OAuth providers
providers = []
try:
from flask import current_app
if hasattr(current_app.appbuilder.sm, 'oauth_remotes'):
providers = list(current_app.appbuilder.sm.oauth_remotes.keys())
except Exception as e:
log.warning(f"Could not get OAuth remotes: {e}")
pass
# Generate CSRF token for the form
csrf_token = generate_csrf()
# Build flashed messages HTML
from flask import get_flashed_messages
messages_html = ""
messages = get_flashed_messages(with_categories=True)
if messages:
for category, message in messages:
alert_class = "alert-error" if category == "error" else
f"alert-{category}"
messages_html += f'<div class="alert
{alert_class}">{html.escape(str(message))}</div>'
# Build OAuth buttons HTML
oauth_buttons = ""
if providers:
for provider in providers:
provider_name = provider.title()
oauth_buttons += f'''
<a href="/login/{provider}{next_param}" class="btn-oauth">
Sign in with {provider_name}
</a>
'''
# Build database login form HTML
db_form = f'''
<form method="POST" action="/unified-login{next_param}">
<input type="hidden" name="csrf_token" value="{csrf_token}"/>
<div class="form-group">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control form-control-lg"
id="username" name="username" required autofocus placeholder="Enter your
username">
</div>
<div class="form-group">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control form-control-lg"
id="password" name="password" required placeholder="Enter your password">
</div>
<button type="submit" class="btn btn-submit btn-lg w-100">Sign
In</button>
</form>
'''
# Build complete HTML page
html_content = f'''
<!DOCTYPE html>
<html>
<head>
<title>Login - Your App Name</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link
href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
rel="stylesheet">
<style>
/* NICE TO HAVE: Box-sizing reset - can skip if you don't need
consistent sizing */
* {{ box-sizing: border-box; }}
/* ESSENTIAL: Basic layout for centering the login form */
body {{
background: #f5f5f5; /* NICE TO HAVE: Specific color - can
use any background */
min-height: 100vh;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, "Helvetica Neue", Arial, sans-serif; /* NICE TO HAVE: Specific fonts -
can use default */
}}
/* ESSENTIAL: Container for login form */
.login-container {{
max-width: 420px;
width: 100%;
background: white;
padding: 48px 40px; /* NICE TO HAVE: Specific padding -
adjust as needed */
border-radius: 8px; /* NICE TO HAVE: Rounded corners - can
remove */
box-shadow: 0 2px 8px rgba(0,0,0,0.1); /* NICE TO HAVE:
Shadow effect - can remove */
}}
/* NICE TO HAVE: Header styling - can simplify */
.login-header {{
text-align: center;
margin-bottom: 32px;
}}
.login-header h1 {{
font-size: 28px; /* NICE TO HAVE: Specific size - adjust
as needed */
font-weight: 600; /* NICE TO HAVE: Bold weight - can use
normal */
color: #1a1a1a; /* NICE TO HAVE: Specific color - can use
default */
margin: 0 0 8px 0;
}}
.login-header p {{
color: #666; /* NICE TO HAVE: Specific color - can use
default */
font-size: 14px; /* NICE TO HAVE: Specific size - adjust
as needed */
margin: 0;
}}
/* ESSENTIAL: OAuth button styling */
.btn-oauth {{
background: white;
border: 1px solid #dadce0;
color: #3c4043;
display: block;
text-align: center;
padding: 12px 16px;
font-size: 14px;
font-weight: 500; /* NICE TO HAVE: Bold weight - can use
normal */
border-radius: 4px; /* NICE TO HAVE: Rounded corners - can
remove */
text-decoration: none;
transition: all 0.2s; /* NICE TO HAVE: Smooth transitions
- can remove */
width: 100%;
margin-bottom: 16px;
}}
/* NICE TO HAVE: Hover effect - can remove if you don't want
hover styling */
.btn-oauth:hover {{
background: #f8f9fa;
box-shadow: 0 1px 3px rgba(0,0,0,0.1); /* NICE TO HAVE:
Shadow on hover - can remove */
color: #3c4043;
text-decoration: none;
}}
/* NICE TO HAVE: Fancy "OR" divider with line - can replace
with simple text or remove */
.divider {{
margin: 24px 0;
text-align: center;
position: relative;
}}
.divider::before {{
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: #e0e0e0;
}}
.divider span {{
background: white;
padding: 0 16px;
position: relative;
color: #999; /* NICE TO HAVE: Specific color - can use
default */
font-size: 13px; /* NICE TO HAVE: Specific size - adjust
as needed */
font-weight: 500; /* NICE TO HAVE: Bold weight - can use
normal */
}}
/* ESSENTIAL: Form field spacing */
.form-group {{
margin-bottom: 20px; /* Adjust spacing as needed */
}}
/* ESSENTIAL: Label styling */
.form-label {{
font-weight: 500; /* NICE TO HAVE: Bold weight - can use
normal */
color: #333; /* NICE TO HAVE: Specific color - can use
default */
margin-bottom: 8px;
font-size: 14px; /* NICE TO HAVE: Specific size - adjust
as needed */
display: block;
text-align: left;
}}
/* ESSENTIAL: Input field styling */
.form-control {{
border: 1px solid #ddd;
border-radius: 4px; /* NICE TO HAVE: Rounded corners - can
remove */
padding: 12px 16px; /* Adjust padding as needed */
font-size: 15px; /* NICE TO HAVE: Specific size - adjust
as needed */
transition: border-color 0.2s; /* NICE TO HAVE: Smooth
transitions - can remove */
width: 100%;
display: block;
}}
/* NICE TO HAVE: Fancy focus state with shadow - can simplify
to just border-color change */
.form-control:focus {{
border-color: #4285f4;
box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.1); /* NICE TO
HAVE: Focus ring - can remove */
outline: none;
}}
/* ESSENTIAL: Submit button styling */
.btn-submit {{
background: #4285f4; /* Use your preferred button color */
border: none;
color: white;
font-weight: 500; /* NICE TO HAVE: Bold weight - can use
normal */
padding: 12px;
border-radius: 4px; /* NICE TO HAVE: Rounded corners - can
remove */
font-size: 15px; /* NICE TO HAVE: Specific size - adjust
as needed */
transition: all 0.2s; /* NICE TO HAVE: Smooth transitions
- can remove */
width: 100%;
margin-top: 8px;
}}
/* NICE TO HAVE: Hover effect - can remove if you don't want
hover styling */
.btn-submit:hover {{
background: #357ae8; /* Slightly darker on hover */
box-shadow: 0 2px 4px rgba(66, 133, 244, 0.3); /* NICE TO
HAVE: Shadow on hover - can remove */
}}
/* ESSENTIAL: Alert box styling (needed for error messages) */
.alert {{
border-radius: 4px; /* NICE TO HAVE: Rounded corners - can
remove */
margin-bottom: 20px;
padding: 12px 16px;
}}
/* ESSENTIAL: Error message styling */
.alert-error {{
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}}
.alert-error {{
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<h1>Welcome to Your App Name</h1>
<p>Sign in to continue</p>
</div>
{messages_html}
{oauth_buttons}
<div class="divider"><span>OR</span></div>
{db_form}
</div>
</body>
</html>
'''
return Response(html_content, mimetype='text/html')
@app.route('/login/', methods=['GET'])
def redirect_to_unified_login():
"""Redirects /login/ to /unified-login to ensure custom page is used."""
next_url = request.args.get('next', '')
return redirect(url_for('unified_login', next=next_url))
print("✓ Unified login route registered at /unified-login")
print("✓ /login/ redirects to /unified-login (shows both OAuth and DB
login)")
```
### 4. Register the Route
In your `superset_config.py`, register the unified login route after the app is
created. You can do this by adding it to the `init_app` method or by using
Flask's `before_first_request` hook:
```python
# In superset_config.py, after defining register_unified_login function
# This will be called by Superset when the app is initialized
def init_app(app):
"""Initialize the Flask app with custom routes."""
register_unified_login(app)
```
Alternatively, if you're using a custom entrypoint or have access to the app
initialization, you can call it directly:
```python
# In your entrypoint or app initialization code
from superset.app import create_app
app = create_app()
register_unified_login(app)
```
## Key Implementation Details
### CSRF Protection
Flask-WTF's CSRF protection is essential for the database login form. The
implementation:
- Generates a CSRF token using `generate_csrf()` on GET requests
- Includes the token as a hidden field in the form
- Validates the token using `validate_csrf()` on POST requests
### OAuth Redirect Handling
The OAuth flow uses Flask-AppBuilder's built-in OAuth handling:
- OAuth buttons link to `/login/{provider}` (e.g., `/login/google`)
- Flask-AppBuilder handles the OAuth callback at `/oauth-authorized/{provider}`
- The custom `auth_user_oauth` method is called after successful OAuth
authentication
## Troubleshooting
### OAuth redirect_uri_mismatch
**Symptom:** OAuth flow fails with `redirect_uri_mismatch` error.
**Solution:**
- Ensure `PREFERRED_URL_SCHEME = "https"` is set
- Verify the redirect URI in your OAuth provider (e.g., Google Cloud Console)
matches exactly: `https://your-domain.com/oauth-authorized/google`
### Database login returns 405 Method Not Allowed
**Symptom:** POST to `/unified-login` returns 405.
**Solution:**
- Ensure the route decorator includes `methods=['GET', 'POST']`
- Verify the form's `action` attribute points to `/unified-login`
### CSRF token missing
**Symptom:** Database login fails with "The CSRF token is missing."
**Solution:**
- Ensure `WTF_CSRF_ENABLED = True` in config
- Verify the hidden CSRF input field is included in the form
- Check that cookies are enabled in the browser
### OAuth user not found
**Symptom:** OAuth flow completes but user is redirected back to login.
**Solution:**
- Verify the user exists in the Superset database with matching email
- Check that the user is active (`active = True`)
- Review logs for the exact email/username being looked up
- Ensure `AUTH_USER_REGISTRATION = False` if you want admin-controlled user
creation
GitHub link:
https://github.com/apache/superset/discussions/32472#discussioncomment-15378094
----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]