fix: OIDC Authentication with PKCE for dev bypass

- Fixed operational auth session bug returning False despite valid Mock Session
- Removed dev user ID hardcoding logic ensuring auth flow uses the defined config.
This commit is contained in:
br committed 2026-08-04 13:49:37 +02:00
1 parent 03b0e33723
commit 8498781c85
2 files changed
+173 -25

No files matched your search

+92 -25
View File
@@ -46,10 +46,11 @@ auth_bp = Blueprint('auth', __name__, template_folder='../templates')
@auth_bp.before_request @auth_bp.before_request
def check_dev_mode_auth(): def check_dev_mode_auth():
""" """
Middleware to handle development mode authentication. Middleware to handle development mode and mock authentication.
If DEV_MODE is enabled and user is not authenticated, If DEV_MODE is enabled and user is not authenticated:
automatically create a dev user session. - If MOCK_AUTH is enabled: create mock user session
- Otherwise: create dev user session
""" """
# Skip for auth routes themselves # Skip for auth routes themselves
if request.path in ['/auth/login', '/auth/callback', '/auth/logout', '/auth/status']: if request.path in ['/auth/login', '/auth/callback', '/auth/logout', '/auth/status']:
@@ -61,31 +62,70 @@ def check_dev_mode_auth():
# In dev mode, auto-authenticate # In dev mode, auto-authenticate
if AuthService.is_dev_mode(): if AuthService.is_dev_mode():
dev_user = AuthService.create_dev_user_session() if AuthService.is_mock_auth_enabled():
session['user_id'] = dev_user['id'] # Use mock authentication
session['user'] = dev_user mock_user = AuthService.create_mock_user_session()
session['authenticated'] = True session['user_id'] = mock_user['id']
session.permanent = True session['user'] = mock_user
session['authenticated'] = True
session.permanent = True
# Ensure user and tenant exist in database # Ensure mock user and tenant exist in database
AuthService.ensure_dev_user_exists() AuthService.ensure_mock_user_exists()
AuthService.ensure_dev_tenant_exists() AuthService.ensure_mock_tenant_exists()
else:
# Use dev mode authentication
dev_user = AuthService.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
AuthService.ensure_dev_user_exists()
AuthService.ensure_dev_tenant_exists()
@auth_bp.route('/login') @auth_bp.route('/login')
def login(): def login():
""" """
Login route - initiates OIDC flow or handles dev mode. Login route - initiates OIDC flow or handles dev/mock mode.
In production: Redirects to Zitadel for OIDC authentication In production: Redirects to Zitadel for OIDC authentication
In development: Creates fake session and redirects to admin In development with MOCK_AUTH: Creates mock session and redirects to admin
In development without MOCK_AUTH: Creates fake session and redirects to admin
Query parameters: Query parameters:
next: URL to redirect to after successful login next: URL to redirect to after successful login
""" """
next_url = request.args.get('next', url_for('admin.dashboard')) next_url = request.args.get('next', url_for('admin.dashboard'))
# Development mode: create fake session and redirect # Mock authentication mode: create mock session and redirect
if AuthService.is_mock_auth_enabled():
mock_user = AuthService.create_mock_user_session()
session.clear()
session['user_id'] = mock_user['id']
session['user'] = mock_user
session['authenticated'] = True
session.permanent = True
# Ensure mock user and tenant exist in database
AuthService.ensure_mock_user_exists()
AuthService.ensure_mock_tenant_exists()
logger.info(f'Mock auth login: {mock_user["email"]}')
AuditLog.log_action(
action='login',
resource_type='auth',
user=User.query.get(mock_user['id']),
tenant=Tenant.query.get(mock_user['tenant_id']),
details={'mode': 'mock', 'mock_auth': True, 'dev_mode': True}
)
return redirect(next_url)
# Development mode (without MOCK_AUTH): create fake session and redirect
if AuthService.is_dev_mode(): if AuthService.is_dev_mode():
dev_user = AuthService.create_dev_user_session() dev_user = AuthService.create_dev_user_session()
session.clear() session.clear()
@@ -122,16 +162,31 @@ def login():
# Configure Zitadel if not already configured # Configure Zitadel if not already configured
if 'zitadel' not in oauth._clients: if 'zitadel' not in oauth._clients:
issuer = current_app.config.get('ZITADEL_ISSUER', '').rstrip('/')
client_secret = current_app.config.get('ZITADEL_CLIENT_SECRET')
# Scopes aus Config lesen oder auf OIDC-Standard-Defaults zurückfallen
scopes = current_app.config.get('ZITADEL_SCOPES')
if not scopes:
scopes = ['openid', 'profile', 'email']
scope_string = ' '.join(scopes) if isinstance(scopes, list) else scopes
zitadel_config = { zitadel_config = {
'client_id': current_app.config.get('ZITADEL_CLIENT_ID'), 'client_id': current_app.config.get('ZITADEL_CLIENT_ID'),
'client_secret': current_app.config.get('ZITADEL_CLIENT_SECRET'), 'client_secret': client_secret,
'authorize_url': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/oauth/authorize", # Automatische Konfiguration über Zitadels Discovery Endpoint
'access_token_url': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/oauth/token", 'server_metadata_url': f"{issuer}/.well-known/openid-configuration",
'userinfo_endpoint': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/userinfo",
'client_kwargs': { 'client_kwargs': {
'scope': ' '.join(current_app.config.get('ZITADEL_SCOPES', [])) 'scope': scope_string,
'code_challenge_method': 'S256', # PKCE Aktivierung
# Falls KEIN Client Secret vorhanden ist (Pure PKCE Public App):
# 'token_endpoint_auth_method': 'none' if not client_secret else 'client_secret_post'
} }
} }
# Nur hinzufügen, wenn kein Secret da ist
if not client_secret:
zitadel_config['client_kwargs']['token_endpoint_auth_method'] = 'none'
oauth.register('zitadel', **zitadel_config) oauth.register('zitadel', **zitadel_config)
# Generate state for CSRF protection # Generate state for CSRF protection
@@ -143,7 +198,7 @@ def login():
redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI', redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI',
url_for('auth.callback', _external=True)) url_for('auth.callback', _external=True))
authorization_url = f"{current_app.config.get('ZITADEL_ISSUER')}/v1/oauth/authorize" authorization_url = f"{current_app.config.get('ZITADEL_ISSUER')}/v2/oauth/authorize"
params = { params = {
'client_id': current_app.config.get('ZITADEL_CLIENT_ID'), 'client_id': current_app.config.get('ZITADEL_CLIENT_ID'),
@@ -167,7 +222,13 @@ def callback():
Handles the callback from Zitadel after successful authentication. Handles the callback from Zitadel after successful authentication.
Exchanges authorization code for tokens and retrieves user info. Exchanges authorization code for tokens and retrieves user info.
In mock auth mode, this route shouldn't be reached, but redirects gracefully.
""" """
if AuthService.is_mock_auth_enabled():
# In mock auth mode, shouldn't reach here, but handle gracefully
logger.debug('Mock auth mode: callback route accessed, redirecting to dashboard')
return redirect(url_for('admin.dashboard'))
if AuthService.is_dev_mode(): if AuthService.is_dev_mode():
# In dev mode, shouldn't reach here, but handle gracefully # In dev mode, shouldn't reach here, but handle gracefully
return redirect(url_for('admin.dashboard')) return redirect(url_for('admin.dashboard'))
@@ -255,13 +316,16 @@ def logout():
Logout route - clears session and redirects to login. Logout route - clears session and redirects to login.
In production: Also revokes tokens with Zitadel if configured In production: Also revokes tokens with Zitadel if configured
In development: Simply clears the session In development/mock: Simply clears the session
""" """
# Get current user for audit logging # Get current user for audit logging
current_user = None current_user = None
if 'user_id' in session: if 'user_id' in session:
current_user = User.query.get(session['user_id']) current_user = User.query.get(session['user_id'])
# Determine auth mode for logging
auth_mode = 'mock' if AuthService.is_mock_auth_enabled() else ('dev' if AuthService.is_dev_mode() else 'oidc')
# Clear session # Clear session
session.clear() session.clear()
@@ -286,7 +350,7 @@ def logout():
action='logout', action='logout',
resource_type='auth', resource_type='auth',
user=current_user, user=current_user,
details={'mode': 'dev' if AuthService.is_dev_mode() else 'oidc'} details={'mode': auth_mode}
) )
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@@ -304,12 +368,14 @@ def status():
return jsonify({ return jsonify({
'authenticated': True, 'authenticated': True,
'user': user_data, 'user': user_data,
'dev_mode': AuthService.is_dev_mode() 'dev_mode': AuthService.is_dev_mode(),
'mock_auth': AuthService.is_mock_auth_enabled()
}) })
return jsonify({ return jsonify({
'authenticated': False, 'authenticated': False,
'dev_mode': AuthService.is_dev_mode() 'dev_mode': AuthService.is_dev_mode(),
'mock_auth': AuthService.is_mock_auth_enabled()
}) })
@@ -334,5 +400,6 @@ def userinfo():
return jsonify({ return jsonify({
'user': user.to_dict(), 'user': user.to_dict(),
'tenants': [tenant.to_dict() for tenant in user.tenants], 'tenants': [tenant.to_dict() for tenant in user.tenants],
'dev_mode': AuthService.is_dev_mode() 'dev_mode': AuthService.is_dev_mode(),
'mock_auth': AuthService.is_mock_auth_enabled()
}) })
+81
View File
@@ -21,6 +21,11 @@ class AuthService:
"""Check if application is in development mode.""" """Check if application is in development mode."""
return current_app.config.get('DEV_MODE', False) return current_app.config.get('DEV_MODE', False)
@staticmethod
def is_mock_auth_enabled() -> bool:
"""Check if mock authentication mode is enabled."""
return current_app.config.get('MOCK_AUTH', False) and current_app.config.get('DEV_MODE', False)
@staticmethod @staticmethod
def ensure_dev_user_exists() -> User: def ensure_dev_user_exists() -> User:
""" """
@@ -96,6 +101,82 @@ class AuthService:
return dev_user return dev_user
@staticmethod
def create_mock_user_session() -> dict:
"""
Create a mock authentication user session with local mock user.
:return: Dictionary with mock user session data
"""
config = current_app.config
# Use configured mock user settings or defaults
mock_user = {
'id': config.get('MOCK_USER_ID', 'mock_user_001'),
'email': config.get('MOCK_USER_EMAIL', 'mock@formeleon.local'),
'name': config.get('MOCK_USER_NAME', 'Mock Admin'),
'role': config.get('MOCK_USER_ROLE', 'admin'),
'sub': f'mock-{uuid.uuid4()}',
'tenant_id': config.get('MOCK_TENANT_ID', 'mock_tenant_001'),
'dev_mode': True,
'mock_auth': True,
'authenticated': True
}
return mock_user
@staticmethod
def ensure_mock_user_exists() -> User:
"""
Ensure the mock user exists in the database.
:return: User object from database
"""
config = current_app.config
user_id = config.get('MOCK_USER_ID', 'mock_user_001')
user = User.query.get(user_id)
if not user:
role = UserRole.ADMIN if config.get('MOCK_USER_ROLE', 'admin') == 'admin' else UserRole.USER
user = User(
id=user_id,
email=config.get('MOCK_USER_EMAIL', 'mock@formeleon.local'),
name=config.get('MOCK_USER_NAME', 'Mock Admin'),
role=role,
sub=f'mock-{uuid.uuid4()}'
)
db.session.add(user)
db.session.commit()
logger.info(f'Created mock user: {user.id}')
return user
@staticmethod
def ensure_mock_tenant_exists() -> Tenant:
"""
Ensure the mock tenant exists in the database.
:return: Tenant object from database
"""
config = current_app.config
tenant_id = config.get('MOCK_TENANT_ID', 'mock_tenant_001')
tenant = Tenant.query.get(tenant_id)
if not tenant:
tenant = Tenant(
id=tenant_id,
name='Mock Tenant',
public_key=f'mock-key-{uuid.uuid4().hex[:16]}',
allowed_origins='*'
)
db.session.add(tenant)
db.session.commit()
logger.info(f'Created mock tenant: {tenant.id}')
return tenant
@staticmethod @staticmethod
def process_oidc_callback(user_info: dict) -> Tuple[Optional[User], Optional[str]]: def process_oidc_callback(user_info: dict) -> Tuple[Optional[User], Optional[str]]:
""" """