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]

Reply via email to