# ============================================================================== # Formeleon - Multi-tenant Form Generator # Author: Björn Runden # Module: routes/auth.py # Description: Authentication routes for Zitadel OIDC integration. # Provides login, logout, and callback handlers with dev mode bypass. # ============================================================================== """ Authentication routes for Formeleon. This module provides: - OIDC authentication via Zitadel (production) - Development mode bypass with fake user injection - Login/logout routes - Session management Routes: GET /auth/login - Initiate OIDC login flow or redirect to admin GET /auth/callback - OIDC callback handler GET /auth/logout - Logout and clear session GET /auth/status - Check current authentication status In development mode (DEV_MODE=true), the login route automatically creates a fake authenticated session without redirecting to Zitadel. """ import uuid import logging from typing import Optional from urllib.parse import urlencode from flask import Blueprint, request, redirect, url_for, session, current_app, jsonify from flask import render_template_string from models import db, User, UserRole, Tenant, AuditLog logger = logging.getLogger(__name__) # Create auth blueprint auth_bp = Blueprint('auth', __name__, template_folder='../templates') def is_dev_mode() -> bool: """Check if application is in development mode.""" return current_app.config.get('DEV_MODE', False) def create_dev_user_session() -> dict: """ Create a development mode user session with fake Zitadel user. :return: Dictionary with user session data """ config = current_app.config # Use configured dev user settings or defaults dev_user = { 'id': config.get('DEV_USER_ID', 'user_dev_admin_001'), 'email': config.get('DEV_USER_EMAIL', 'dev@formeleon.local'), 'name': config.get('DEV_USER_NAME', 'Dev Admin'), 'role': config.get('DEV_USER_ROLE', 'admin'), 'sub': f'dev-{uuid.uuid4()}', 'tenant_id': config.get('DEV_TENANT_ID', 'tenant_dev_123'), 'dev_mode': True, 'authenticated': True } return dev_user def ensure_dev_user_exists() -> User: """ Ensure the development user exists in the database. :return: User object from database """ config = current_app.config user_id = config.get('DEV_USER_ID', 'user_dev_admin_001') user = User.query.get(user_id) if not user: role = UserRole.ADMIN if config.get('DEV_USER_ROLE', 'admin') == 'admin' else UserRole.USER user = User( id=user_id, email=config.get('DEV_USER_EMAIL', 'dev@formeleon.local'), name=config.get('DEV_USER_NAME', 'Dev Admin'), role=role, sub=f'dev-{uuid.uuid4()}' ) db.session.add(user) db.session.commit() logger.info(f'Created dev user: {user.id}') return user def ensure_dev_tenant_exists() -> Tenant: """ Ensure the development tenant exists in the database. :return: Tenant object from database """ config = current_app.config tenant_id = config.get('DEV_TENANT_ID', 'tenant_dev_123') tenant = Tenant.query.get(tenant_id) if not tenant: tenant = Tenant( id=tenant_id, name='Development Tenant', public_key=f'dev-key-{uuid.uuid4().hex[:16]}', allowed_origins='*' ) db.session.add(tenant) db.session.commit() logger.info(f'Created dev tenant: {tenant.id}') return tenant @auth_bp.before_request def check_dev_mode_auth(): """ Middleware to handle development mode authentication. If DEV_MODE is enabled and user is not authenticated, automatically create a dev user session. """ # Skip for auth routes themselves if request.path in ['/auth/login', '/auth/callback', '/auth/logout', '/auth/status']: return # Skip if already authenticated if 'user_id' in session and session.get('authenticated'): return # In dev mode, auto-authenticate if is_dev_mode(): dev_user = create_dev_user_session() session['user_id'] = dev_user['id'] session['user'] = dev_user session['authenticated'] = True session.permanent = True # Ensure user and tenant exist in database ensure_dev_user_exists() ensure_dev_tenant_exists() @auth_bp.route('/login') def login(): """ Login route - initiates OIDC flow or handles dev mode. In production: Redirects to Zitadel for OIDC authentication In development: Creates fake session and redirects to admin Query parameters: next: URL to redirect to after successful login """ next_url = request.args.get('next', url_for('admin.dashboard')) # Development mode: create fake session and redirect if is_dev_mode(): dev_user = create_dev_user_session() session.clear() session['user_id'] = dev_user['id'] session['user'] = dev_user session['authenticated'] = True session.permanent = True # Ensure user and tenant exist in database ensure_dev_user_exists() ensure_dev_tenant_exists() logger.info(f'Dev mode login: {dev_user["email"]}') AuditLog.log_action( action='login', resource_type='auth', user=User.query.get(dev_user['id']), tenant=Tenant.query.get(dev_user['tenant_id']), details={'mode': 'dev', 'dev_mode': True} ) return redirect(next_url) # Production mode: redirect to Zitadel OIDC if not current_app.config['ZITADEL_ISSUER']: return jsonify({'error': 'OIDC not configured'}), 500 # Build authorization URL from authlib.integrations.flask_client import OAuth from flask import current_app oauth = OAuth(current_app) # Configure Zitadel if not already configured if 'zitadel' not in oauth._clients: zitadel_config = { 'client_id': current_app.config.get('ZITADEL_CLIENT_ID'), 'client_secret': current_app.config.get('ZITADEL_CLIENT_SECRET'), 'authorize_url': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/oauth/authorize", 'access_token_url': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/oauth/token", 'userinfo_endpoint': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/userinfo", 'client_kwargs': { 'scope': ' '.join(current_app.config.get('ZITADEL_SCOPES', [])) } } oauth.register('zitadel', **zitadel_config) # Generate state for CSRF protection state = str(uuid.uuid4()) session['oauth_state'] = state session['oauth_next'] = next_url # Build authorization URL redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI', url_for('auth.callback', _external=True)) authorization_url = f"{current_app.config.get('ZITADEL_ISSUER')}/v1/oauth/authorize" params = { 'client_id': current_app.config.get('ZITADEL_CLIENT_ID'), 'redirect_uri': redirect_uri, 'response_type': 'code', 'scope': ' '.join(current_app.config.get('ZITADEL_SCOPES', [])), 'state': state, 'nonce': str(uuid.uuid4()) } auth_url = f"{authorization_url}?{urlencode(params)}" logger.debug(f'Redirecting to OIDC provider: {auth_url}') return redirect(auth_url) @auth_bp.route('/callback') def callback(): """ OIDC callback handler. Handles the callback from Zitadel after successful authentication. Exchanges authorization code for tokens and retrieves user info. """ if is_dev_mode(): # In dev mode, shouldn't reach here, but handle gracefully return redirect(url_for('admin.dashboard')) # Verify state for CSRF protection state = request.args.get('state') stored_state = session.pop('oauth_state', None) if not state or state != stored_state: logger.error('OIDC callback: Invalid state parameter') return jsonify({'error': 'Invalid state parameter'}), 403 # Get authorization code code = request.args.get('code') if not code: error = request.args.get('error', 'No authorization code received') logger.error(f'OIDC callback error: {error}') return jsonify({'error': error}), 400 # Exchange code for tokens try: from authlib.integrations.flask_client import OAuth oauth = OAuth(current_app) redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI', url_for('auth.callback', _external=True)) zitadel = oauth.create_client('zitadel') # Get access token token = zitadel.authorize_access_token( redirect_uri=redirect_uri, code=code ) # Get user info user_info = zitadel.userinfo(token=token) logger.debug(f'OIDC userinfo: {user_info}') # Extract user data sub = user_info.get('sub') email = user_info.get('email') name = user_info.get('name', user_info.get('preferred_username', 'Unknown')) if not email: return jsonify({'error': 'No email in user info'}), 400 # Create or update user user = User.query.filter_by(sub=sub).first() if not user: user_id = f'user_{email.split("@")[0]}_{str(uuid.uuid4())[:8]}' user = User( id=user_id, email=email, name=name, role=UserRole.USER, # Default role for new OIDC users sub=sub ) db.session.add(user) db.session.commit() logger.info(f'Created new user from OIDC: {user.id}') else: # Update existing user user.name = name user.email = email db.session.commit() # Store in session session.clear() session['user_id'] = user.id session['user'] = { 'id': user.id, 'email': user.email, 'name': user.name, 'role': user.role.value, 'sub': user.sub, 'authenticated': True, 'dev_mode': False } session['authenticated'] = True session['access_token'] = token['access_token'] session['refresh_token'] = token.get('refresh_token') session['token_expires'] = token.get('expires_at') session.permanent = True # Log the login AuditLog.log_action( action='login', resource_type='auth', user=user, details={'mode': 'oidc', 'provider': 'zitadel'} ) # Redirect to next URL or dashboard next_url = session.pop('oauth_next', url_for('admin.dashboard')) return redirect(next_url) except Exception as e: logger.error(f'OIDC callback failed: {e}') return jsonify({'error': 'Authentication failed'}), 500 @auth_bp.route('/logout') def logout(): """ Logout route - clears session and redirects to login. In production: Also revokes tokens with Zitadel if configured In development: Simply clears the session """ # Get current user for audit logging current_user = None if 'user_id' in session: current_user = User.query.get(session['user_id']) # Clear session session.clear() # In production, revoke token with Zitadel if not is_dev_mode() and 'access_token' in session: try: from authlib.integrations.flask_client import OAuth oauth = OAuth(current_app) zitadel = oauth.create_client('zitadel') # Revoke token zitadel.revoke_token( token=session['access_token'], token_type_hint='access_token' ) except Exception as e: logger.warning(f'Failed to revoke token: {e}') # Log the logout if current_user: AuditLog.log_action( action='logout', resource_type='auth', user=current_user, details={'mode': 'dev' if is_dev_mode() else 'oidc'} ) return redirect(url_for('auth.login')) @auth_bp.route('/status') def status(): """ Check current authentication status. :return: JSON with current authentication status """ if 'user_id' in session and session.get('authenticated'): user_data = session.get('user', {}) return jsonify({ 'authenticated': True, 'user': user_data, 'dev_mode': is_dev_mode() }) return jsonify({ 'authenticated': False, 'dev_mode': is_dev_mode() }) @auth_bp.route('/userinfo') def userinfo(): """ Get detailed user information. Requires authentication. :return: JSON with user information """ if not ('user_id' in session and session.get('authenticated')): return jsonify({'error': 'Unauthorized'}), 401 user_id = session['user_id'] user = User.query.get(user_id) if not user: return jsonify({'error': 'User not found'}), 404 return jsonify({ 'user': user.to_dict(), 'tenants': [tenant.to_dict() for tenant in user.tenants], 'dev_mode': is_dev_mode() })