From c6a711aa27f0ea110bae0af389475484d50bb66a Mon Sep 17 00:00:00 2001 From: Petr Date: Mon, 9 Mar 2026 13:02:08 +0100 Subject: [PATCH] Extract pluggable auth provider system into auth/ package Replace hardcoded Google OAuth + password auth registration with auto-discovered auth providers. Each provider in auth//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 --- auth/__init__.py | 102 +++++++++++++++++++++++++++++ auth/desktop/__init__.py | 0 auth/desktop/provider.py | 53 +++++++++++++++ auth/google/__init__.py | 0 auth/google/provider.py | 124 ++++++++++++++++++++++++++++++++++++ auth/password/__init__.py | 0 auth/password/provider.py | 52 +++++++++++++++ webapp/app.py | 29 ++++----- webapp/auth.py | 86 ++++++------------------- webapp/templates/login.html | 27 ++++---- 10 files changed, 374 insertions(+), 99 deletions(-) create mode 100644 auth/__init__.py create mode 100644 auth/desktop/__init__.py create mode 100644 auth/desktop/provider.py create mode 100644 auth/google/__init__.py create mode 100644 auth/google/provider.py create mode 100644 auth/password/__init__.py create mode 100644 auth/password/provider.py diff --git a/auth/__init__.py b/auth/__init__.py new file mode 100644 index 0000000..37fb8d4 --- /dev/null +++ b/auth/__init__.py @@ -0,0 +1,102 @@ +""" +Pluggable authentication provider system. + +Each auth provider lives in auth//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 diff --git a/auth/desktop/__init__.py b/auth/desktop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auth/desktop/provider.py b/auth/desktop/provider.py new file mode 100644 index 0000000..04d79ba --- /dev/null +++ b/auth/desktop/provider.py @@ -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() diff --git a/auth/google/__init__.py b/auth/google/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auth/google/provider.py b/auth/google/provider.py new file mode 100644 index 0000000..d0e1d62 --- /dev/null +++ b/auth/google/provider.py @@ -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 = ( + '' + '" + '" + '' + '' + "" +) + + +@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 @{Config.ALLOWED_DOMAIN} 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() diff --git a/auth/password/__init__.py b/auth/password/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auth/password/provider.py b/auth/password/provider.py new file mode 100644 index 0000000..f710f57 --- /dev/null +++ b/auth/password/provider.py @@ -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() diff --git a/webapp/app.py b/webapp/app.py index f20235a..de1c87d 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -15,9 +15,9 @@ from pathlib import Path 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 .desktop_auth import desktop_bp, require_desktop_auth +from .desktop_auth import require_desktop_auth from .notification_images import images_bp from .account_service import get_account_details from .sync_settings_service import get_sync_settings, update_sync_settings @@ -29,14 +29,6 @@ try: except ImportError: JIRA_AVAILABLE = False 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 .corporate_memory_service import ( get_knowledge, @@ -73,17 +65,20 @@ def create_app() -> Flask: for error in errors: logger.warning(f"Configuration warning: {error}") - # Initialize OAuth - init_oauth(app) - - # Register blueprints + # Register core auth blueprint (login_required, login page, logout) 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) if JIRA_AVAILABLE and 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_routes(app) diff --git a/webapp/auth.py b/webapp/auth.py index 9ff1b2b..f81a9de 100644 --- a/webapp/auth.py +++ b/webapp/auth.py @@ -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.py. """ import functools import logging -from authlib.integrations.flask_client import OAuth -from flask import Blueprint, current_app, flash, redirect, session, url_for +from flask import Blueprint, flash, redirect, render_template, session, url_for from .config import Config logger = logging.getLogger(__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): @@ -67,58 +56,19 @@ def validate_email_domain(email: str) -> bool: @auth_bp.route("/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: return redirect(url_for("dashboard")) - from flask import render_template - return render_template("login.html") + from auth import discover_providers - -@auth_bp.route("/login/google") -def login_google(): - """Initiate Google OAuth flow.""" - redirect_uri = url_for("auth.authorize", _external=True) - return oauth.google.authorize_redirect(redirect_uri) - - -@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")) + providers = discover_providers() + login_buttons = [ + p.get_login_button() + for p in providers + if p.get_login_button().get("visible", True) + ] + return render_template("login.html", login_buttons=login_buttons) @auth_bp.route("/logout") diff --git a/webapp/templates/login.html b/webapp/templates/login.html index f2bf4cd..835bd4c 100644 --- a/webapp/templates/login.html +++ b/webapp/templates/login.html @@ -95,31 +95,30 @@ Access your AI data analysis workspace

- - - - - - - - Sign in with Google + {% for btn in login_buttons %} + + {% if btn.icon_html %}{{ btn.icon_html|safe }}{% endif %} + {{ btn.text }} + {% if btn.subtitle %} + {% endif %} + {% if not loop.last %}
or
+ {% endif %} + {% endfor %} - - Sign in with Email - - + {% if not login_buttons %} + {% endif %}