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 %}
- For @{{ config.ALLOWED_DOMAIN }} email addresses.
+ {{ btn.subtitle|safe }}
+ {% endif %}
+ {% if not loop.last %}
or
+ {% endif %}
+ {% endfor %}
-
- Sign in with Email
-
-
+ {% if not login_buttons %}
- For external users (investors, partners).
+ No authentication providers are configured. Please set up at least one provider.
+ {% endif %}