Extract pluggable auth provider system into auth/ package

Replace hardcoded Google OAuth + password auth registration with
auto-discovered auth providers. Each provider in auth/<name>/provider.py
implements AuthProvider ABC and is automatically registered at startup.

- auth/__init__.py: AuthProvider ABC + discover_providers() scanner
- auth/google/: Google OAuth provider (extracted from webapp/auth.py)
- auth/password/: Email/password provider (delegates to webapp/password_auth)
- auth/desktop/: Desktop JWT auth (API-only, not visible on login page)
- webapp/auth.py: stripped to core infra (login_required, /login, /logout)
- webapp/app.py: auto-discovery loop replaces manual blueprint registration
- login.html: dynamic provider buttons via Jinja loop
This commit is contained in:
Petr 2026-03-09 13:02:08 +01:00
parent f2d3d156e3
commit c6a711aa27
10 changed files with 374 additions and 99 deletions

102
auth/__init__.py Normal file
View file

@ -0,0 +1,102 @@
"""
Pluggable authentication provider system.
Each auth provider lives in auth/<name>/provider.py and implements AuthProvider.
Providers are auto-discovered and registered with the Flask app.
To add a new provider (e.g., Okta):
1. Create auth/okta/provider.py
2. Implement AuthProvider subclass
3. Export `provider` instance at module level
4. That's it - no changes to core code needed.
"""
import importlib
import logging
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional
from flask import Blueprint
logger = logging.getLogger(__name__)
class AuthProvider(ABC):
"""Base class for authentication providers."""
@abstractmethod
def get_name(self) -> str:
"""Internal name (e.g., 'google', 'password')."""
@abstractmethod
def get_blueprint(self) -> Blueprint:
"""Flask blueprint with auth routes."""
@abstractmethod
def get_login_button(self) -> dict:
"""Login button definition for the login page.
Returns dict with:
text: str - Button label (e.g., "Sign in with Google")
url: str - Route URL (e.g., "/login/google")
icon_html: str - SVG or HTML for the icon
subtitle: str - Optional help text below button
order: int - Sort order (lower = higher on page)
css_class: str - Optional CSS class for the button (e.g., "btn-google")
visible: bool - Whether to show on login page (default True)
"""
def is_available(self) -> bool:
"""Check if provider is configured and ready.
Override to check env vars, API keys, etc.
Returns False to skip this provider."""
return True
def get_display_name(self) -> str:
"""Human-readable name for UI."""
return self.get_name().title()
def init_app(self, app) -> None:
"""Optional: initialize provider with Flask app (e.g., for OAuth setup)."""
pass
def discover_providers() -> list[AuthProvider]:
"""Auto-discover auth providers from auth/*/provider.py.
Each provider module must export a `provider` instance of AuthProvider.
Providers are sorted by login button order.
Only available providers (is_available() == True) are returned.
"""
providers = []
auth_dir = Path(__file__).parent
for subdir in sorted(auth_dir.iterdir()):
if not subdir.is_dir() or subdir.name.startswith("_"):
continue
provider_file = subdir / "provider.py"
if not provider_file.exists():
continue
try:
mod = importlib.import_module(f"auth.{subdir.name}.provider")
provider_instance = getattr(mod, "provider", None)
if provider_instance and isinstance(provider_instance, AuthProvider):
if provider_instance.is_available():
providers.append(provider_instance)
logger.info(f"Auth provider loaded: {provider_instance.get_name()}")
else:
logger.debug(
f"Auth provider skipped (not available): {subdir.name}"
)
else:
logger.warning(
f"Auth provider {subdir.name} has no 'provider' instance"
)
except Exception as e:
logger.warning(f"Failed to load auth provider {subdir.name}: {e}")
# Sort by login button order
providers.sort(key=lambda p: p.get_login_button().get("order", 50))
return providers

0
auth/desktop/__init__.py Normal file
View file

53
auth/desktop/provider.py Normal file
View file

@ -0,0 +1,53 @@
"""
Desktop JWT authentication provider.
Wraps the existing webapp/desktop_auth.py blueprint.
This is NOT a login provider (no login button) - it provides
JWT-based API authentication for the native desktop application.
"""
import logging
from flask import Blueprint
from auth import AuthProvider
from webapp.config import Config
logger = logging.getLogger(__name__)
class DesktopAuthProvider(AuthProvider):
"""Desktop app JWT authentication provider."""
def get_name(self) -> str:
return "desktop"
def get_display_name(self) -> str:
return "Desktop App"
def get_blueprint(self) -> Blueprint:
from webapp.desktop_auth import desktop_bp
return desktop_bp
def get_login_button(self) -> dict:
return {
"text": "",
"url": "",
"icon_html": "",
"subtitle": "",
"order": 100,
"css_class": "",
"visible": False,
}
def is_available(self) -> bool:
return bool(Config.DESKTOP_JWT_SECRET)
def init_app(self, app) -> None:
"""No additional initialization needed."""
pass
# Module-level provider instance for auto-discovery
provider = DesktopAuthProvider()

0
auth/google/__init__.py Normal file
View file

124
auth/google/provider.py Normal file
View file

@ -0,0 +1,124 @@
"""
Google OAuth authentication provider.
Handles Google Sign-In flow with domain validation.
Extracted from webapp/auth.py - the OAuth-specific routes.
"""
import logging
from authlib.integrations.flask_client import OAuth
from flask import Blueprint, flash, redirect, session, url_for
from auth import AuthProvider
from webapp.auth import validate_email_domain
from webapp.config import Config
logger = logging.getLogger(__name__)
google_bp = Blueprint("google_auth", __name__)
oauth = OAuth()
# Google SVG icon for the login button
_GOOGLE_ICON_HTML = (
'<svg class="google-icon" viewBox="0 0 24 24" width="24" height="24">'
'<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 '
"1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z\"/>"
'<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 '
"1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z\"/>"
'<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07'
'H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>'
'<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 '
'14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>'
"</svg>"
)
@google_bp.route("/login/google")
def login_google():
"""Initiate Google OAuth flow."""
redirect_uri = url_for("google_auth.authorize", _external=True)
return oauth.google.authorize_redirect(redirect_uri)
@google_bp.route("/authorize")
def authorize():
"""Handle OAuth callback from Google."""
try:
token = oauth.google.authorize_access_token()
userinfo = token.get("userinfo")
if not userinfo:
logger.warning("No userinfo in OAuth response")
flash("Failed to get user information from Google.", "error")
return redirect(url_for("auth.login"))
email = userinfo.get("email", "")
name = userinfo.get("name", "")
# Validate domain
if not validate_email_domain(email):
logger.warning(f"Login attempt from non-allowed domain: {email}")
flash(
f"Only @{Config.ALLOWED_DOMAIN} email addresses are allowed.", "error"
)
return redirect(url_for("auth.login"))
# Store user info in session (shared contract across all providers)
session["user"] = {
"email": email,
"name": name,
"picture": userinfo.get("picture", ""),
}
logger.info(f"User logged in via Google: {email}")
return redirect(url_for("dashboard"))
except Exception as e:
logger.exception(f"OAuth error: {e}")
flash("Authentication failed. Please try again.", "error")
return redirect(url_for("auth.login"))
class GoogleAuthProvider(AuthProvider):
"""Google OAuth authentication provider."""
def get_name(self) -> str:
return "google"
def get_display_name(self) -> str:
return "Google"
def get_blueprint(self) -> Blueprint:
return google_bp
def get_login_button(self) -> dict:
return {
"text": "Sign in with Google",
"url": "/login/google",
"icon_html": _GOOGLE_ICON_HTML,
"subtitle": f'For <strong>@{Config.ALLOWED_DOMAIN}</strong> email addresses.',
"order": 10,
"css_class": "btn-google",
"visible": True,
}
def is_available(self) -> bool:
return bool(Config.GOOGLE_CLIENT_ID)
def init_app(self, app) -> None:
"""Initialize OAuth with the Flask app."""
oauth.init_app(app)
oauth.register(
name="google",
client_id=Config.GOOGLE_CLIENT_ID,
client_secret=Config.GOOGLE_CLIENT_SECRET,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={
"scope": "openid email profile",
},
)
# Module-level provider instance for auto-discovery
provider = GoogleAuthProvider()

View file

52
auth/password/provider.py Normal file
View file

@ -0,0 +1,52 @@
"""
Email/password authentication provider.
Wraps the existing webapp/password_auth.py blueprint.
Available only when SENDGRID_API_KEY is configured.
"""
import logging
from flask import Blueprint
from auth import AuthProvider
from webapp.config import Config
logger = logging.getLogger(__name__)
class PasswordAuthProvider(AuthProvider):
"""Email/password authentication provider for external users."""
def get_name(self) -> str:
return "password"
def get_display_name(self) -> str:
return "Email"
def get_blueprint(self) -> Blueprint:
from webapp.password_auth import password_auth_bp
return password_auth_bp
def get_login_button(self) -> dict:
return {
"text": "Sign in with Email",
"url": "/login/email",
"icon_html": "",
"subtitle": "For external users (investors, partners).",
"order": 20,
"css_class": "btn-secondary",
"visible": True,
}
def is_available(self) -> bool:
return bool(Config.SENDGRID_API_KEY)
def init_app(self, app) -> None:
"""No additional initialization needed."""
pass
# Module-level provider instance for auto-discovery
provider = PasswordAuthProvider()

View file

@ -15,9 +15,9 @@ from pathlib import Path
from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for
from .auth import auth_bp, init_oauth, login_required from .auth import auth_bp, login_required
from .config import Config from .config import Config
from .desktop_auth import desktop_bp, require_desktop_auth from .desktop_auth import require_desktop_auth
from .notification_images import images_bp from .notification_images import images_bp
from .account_service import get_account_details from .account_service import get_account_details
from .sync_settings_service import get_sync_settings, update_sync_settings from .sync_settings_service import get_sync_settings, update_sync_settings
@ -29,14 +29,6 @@ try:
except ImportError: except ImportError:
JIRA_AVAILABLE = False JIRA_AVAILABLE = False
jira_bp = None jira_bp = None
# Password auth is optional - requires SENDGRID_API_KEY
try:
from .password_auth import password_auth_bp
PASSWORD_AUTH_AVAILABLE = True
except ImportError:
PASSWORD_AUTH_AVAILABLE = False
password_auth_bp = None
from .telegram_service import get_telegram_status, link_telegram, unlink_telegram from .telegram_service import get_telegram_status, link_telegram, unlink_telegram
from .corporate_memory_service import ( from .corporate_memory_service import (
get_knowledge, get_knowledge,
@ -73,17 +65,20 @@ def create_app() -> Flask:
for error in errors: for error in errors:
logger.warning(f"Configuration warning: {error}") logger.warning(f"Configuration warning: {error}")
# Initialize OAuth # Register core auth blueprint (login_required, login page, logout)
init_oauth(app)
# Register blueprints
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(desktop_bp)
# Auto-discover and register auth providers
from auth import discover_providers
for provider_instance in discover_providers():
provider_instance.init_app(app)
app.register_blueprint(provider_instance.get_blueprint())
# Register other blueprints
app.register_blueprint(images_bp) app.register_blueprint(images_bp)
if JIRA_AVAILABLE and jira_bp: if JIRA_AVAILABLE and jira_bp:
app.register_blueprint(jira_bp) app.register_blueprint(jira_bp)
if PASSWORD_AUTH_AVAILABLE and password_auth_bp:
app.register_blueprint(password_auth_bp)
# Register main routes # Register main routes
register_routes(app) register_routes(app)

View file

@ -1,36 +1,25 @@
""" """
Google OAuth authentication module. Core authentication module - shared infrastructure.
Handles Google Sign-In flow and domain validation. Provides:
- login_required decorator (used by all auth methods)
- validate_email_domain() (used by all auth providers)
- /login route (dynamically renders available auth providers)
- /logout route
Auth provider-specific logic lives in auth/<provider>/provider.py.
""" """
import functools import functools
import logging import logging
from authlib.integrations.flask_client import OAuth from flask import Blueprint, flash, redirect, render_template, session, url_for
from flask import Blueprint, current_app, flash, redirect, session, url_for
from .config import Config from .config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
auth_bp = Blueprint("auth", __name__) auth_bp = Blueprint("auth", __name__)
oauth = OAuth()
def init_oauth(app):
"""Initialize OAuth with the Flask app."""
oauth.init_app(app)
oauth.register(
name="google",
client_id=Config.GOOGLE_CLIENT_ID,
client_secret=Config.GOOGLE_CLIENT_SECRET,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={
"scope": "openid email profile",
},
)
def login_required(f): def login_required(f):
@ -67,58 +56,19 @@ def validate_email_domain(email: str) -> bool:
@auth_bp.route("/login") @auth_bp.route("/login")
def login(): def login():
"""Show login page or redirect to dashboard if already logged in.""" """Show login page with dynamically discovered auth providers."""
if "user" in session: if "user" in session:
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
from flask import render_template
return render_template("login.html") from auth import discover_providers
providers = discover_providers()
@auth_bp.route("/login/google") login_buttons = [
def login_google(): p.get_login_button()
"""Initiate Google OAuth flow.""" for p in providers
redirect_uri = url_for("auth.authorize", _external=True) if p.get_login_button().get("visible", True)
return oauth.google.authorize_redirect(redirect_uri) ]
return render_template("login.html", login_buttons=login_buttons)
@auth_bp.route("/authorize")
def authorize():
"""Handle OAuth callback from Google."""
try:
token = oauth.google.authorize_access_token()
userinfo = token.get("userinfo")
if not userinfo:
logger.warning("No userinfo in OAuth response")
flash("Failed to get user information from Google.", "error")
return redirect(url_for("auth.login"))
email = userinfo.get("email", "")
name = userinfo.get("name", "")
# Validate domain
if not validate_email_domain(email):
logger.warning(f"Login attempt from non-allowed domain: {email}")
flash(
f"Only @{Config.ALLOWED_DOMAIN} email addresses are allowed.", "error"
)
return redirect(url_for("auth.login"))
# Store user info in session
session["user"] = {
"email": email,
"name": name,
"picture": userinfo.get("picture", ""),
}
logger.info(f"User logged in: {email}")
return redirect(url_for("dashboard"))
except Exception as e:
logger.exception(f"OAuth error: {e}")
flash("Authentication failed. Please try again.", "error")
return redirect(url_for("auth.login"))
@auth_bp.route("/logout") @auth_bp.route("/logout")

View file

@ -95,31 +95,30 @@
Access your AI data analysis workspace Access your AI data analysis workspace
</p> </p>
<a href="{{ url_for('auth.login_google') }}" class="btn btn-google"> {% for btn in login_buttons %}
<svg class="google-icon" viewBox="0 0 24 24" width="24" height="24"> <a href="{{ btn.url }}" class="btn {{ btn.css_class|default('btn-secondary') }}" style="width: 100%; max-width: 280px;">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/> {% if btn.icon_html %}{{ btn.icon_html|safe }}{% endif %}
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/> {{ btn.text }}
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Sign in with Google
</a> </a>
{% if btn.subtitle %}
<p class="login-note"> <p class="login-note">
For <strong>@{{ config.ALLOWED_DOMAIN }}</strong> email addresses. {{ btn.subtitle|safe }}
</p> </p>
{% endif %}
{% if not loop.last %}
<div class="divider"> <div class="divider">
<span>or</span> <span>or</span>
</div> </div>
{% endif %}
{% endfor %}
<a href="{{ url_for('password_auth.login_email') }}" class="btn btn-secondary" style="width: 100%; max-width: 280px;"> {% if not login_buttons %}
Sign in with Email
</a>
<p class="login-note"> <p class="login-note">
For external users (investors, partners). No authentication providers are configured. Please set up at least one provider.
</p> </p>
{% endif %}
</div> </div>
</div> </div>
</div> </div>