diff --git a/auth/email/__init__.py b/auth/email/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/auth/email/provider.py b/auth/email/provider.py
new file mode 100644
index 0000000..1dae7e0
--- /dev/null
+++ b/auth/email/provider.py
@@ -0,0 +1,273 @@
+"""
+Email magic link authentication provider.
+
+Users enter their email, receive a magic link, click it and they're logged in.
+No passwords needed. Domain restriction ensures only allowed users can access.
+
+Email delivery modes:
+1. SMTP relay (recommended) - configure SMTP_HOST, SMTP_PORT, etc. in .env
+2. Console mode (development) - link printed to server log, shown in browser
+"""
+
+import logging
+import smtplib
+import time
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+
+from flask import (
+ Blueprint,
+ current_app,
+ flash,
+ redirect,
+ render_template,
+ request,
+ session,
+ url_for,
+)
+from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
+
+from auth import AuthProvider
+from webapp.auth import validate_email_domain
+from webapp.config import Config
+
+logger = logging.getLogger(__name__)
+
+email_bp = Blueprint("email_auth", __name__)
+
+# SVG envelope icon for the login button
+_EMAIL_ICON_HTML = (
+ '"
+)
+
+
+def _get_serializer() -> URLSafeTimedSerializer:
+ """Create token serializer using the app secret key."""
+ return URLSafeTimedSerializer(Config.SECRET_KEY, salt="email-magic-link")
+
+
+def _generate_magic_token(email: str) -> str:
+ """Generate a signed, time-limited token containing the email."""
+ s = _get_serializer()
+ return s.dumps({"email": email.lower(), "t": int(time.time())})
+
+
+def _verify_magic_token(token: str, max_age_seconds: int = 900) -> str | None:
+ """Verify magic link token. Returns email if valid, None otherwise.
+
+ Args:
+ token: The signed token from the magic link URL.
+ max_age_seconds: Token validity period (default 15 minutes).
+
+ Returns:
+ Email address if token is valid, None otherwise.
+ """
+ s = _get_serializer()
+ try:
+ data = s.loads(token, max_age=max_age_seconds)
+ return data.get("email")
+ except SignatureExpired:
+ logger.warning("Magic link token expired")
+ return None
+ except BadSignature:
+ logger.warning("Invalid magic link token")
+ return None
+
+
+def _send_magic_email(email: str, magic_url: str) -> bool:
+ """Send magic link email via SMTP relay.
+
+ Returns True if sent successfully, False otherwise.
+ """
+ smtp_host = Config.SMTP_HOST
+ if not smtp_host:
+ return False
+
+ msg = MIMEMultipart("alternative")
+ msg["Subject"] = f"Sign in to {Config.INSTANCE_NAME}"
+ msg["From"] = Config.SMTP_FROM
+ msg["To"] = email
+
+ text_body = (
+ f"Sign in to {Config.INSTANCE_NAME}\n\n"
+ f"Click the link below to sign in:\n{magic_url}\n\n"
+ f"This link expires in 15 minutes.\n"
+ f"If you didn't request this, ignore this email."
+ )
+
+ html_body = f"""
+
+
+ Sign in to {Config.INSTANCE_NAME}
+ Click the button below to sign in:
+
+
+ Sign In
+
+
+
+ This link expires in 15 minutes.
+ If you didn't request this, ignore this email.
+
+
+
+ Or copy and paste this URL into your browser:
+ {magic_url}
+
+
+"""
+
+ msg.attach(MIMEText(text_body, "plain"))
+ msg.attach(MIMEText(html_body, "html"))
+
+ try:
+ smtp_port = Config.SMTP_PORT
+ use_tls = Config.SMTP_USE_TLS
+
+ if smtp_port == 465:
+ server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10)
+ else:
+ server = smtplib.SMTP(smtp_host, smtp_port, timeout=10)
+ if use_tls:
+ server.starttls()
+
+ smtp_user = Config.SMTP_USER
+ smtp_password = Config.SMTP_PASSWORD
+ if smtp_user and smtp_password:
+ server.login(smtp_user, smtp_password)
+
+ server.sendmail(Config.SMTP_FROM, [email], msg.as_string())
+ server.quit()
+ logger.info("Magic link email sent to %s via SMTP", email)
+ return True
+
+ except Exception as e:
+ logger.error("Failed to send magic link email to %s: %s", email, e)
+ return False
+
+
+# --- Routes ---
+
+
+@email_bp.route("/login/email")
+def login_email_form():
+ """Show email input form."""
+ return render_template(
+ "login_magic_link.html",
+ allowed_domain=Config.ALLOWED_DOMAIN,
+ )
+
+
+@email_bp.route("/login/email/send", methods=["POST"])
+def send_magic_link():
+ """Validate email domain and send magic link."""
+ email = request.form.get("email", "").strip().lower()
+
+ if not email:
+ flash("Please enter your email address.", "error")
+ return redirect(url_for("email_auth.login_email_form"))
+
+ if not validate_email_domain(email):
+ flash(
+ f"Only @{Config.ALLOWED_DOMAIN} email addresses are allowed.",
+ "error",
+ )
+ return redirect(url_for("email_auth.login_email_form"))
+
+ # Generate magic link
+ token = _generate_magic_token(email)
+ magic_url = url_for("email_auth.verify_magic_link", token=token, _external=True)
+
+ # Try SMTP first, fall back to console mode
+ smtp_sent = _send_magic_email(email, magic_url)
+
+ if smtp_sent:
+ flash("Check your email for the sign-in link.", "info")
+ return render_template(
+ "login_magic_link_sent.html",
+ email=email,
+ console_mode=False,
+ )
+ else:
+ # Console/development mode - show link directly
+ logger.info("MAGIC LINK for %s: %s", email, magic_url)
+ return render_template(
+ "login_magic_link_sent.html",
+ email=email,
+ magic_url=magic_url,
+ console_mode=True,
+ )
+
+
+@email_bp.route("/login/email/verify/")
+def verify_magic_link(token: str):
+ """Verify magic link token and log user in."""
+ email = _verify_magic_token(token)
+
+ if not email:
+ flash("This sign-in link has expired or is invalid. Please try again.", "error")
+ return redirect(url_for("email_auth.login_email_form"))
+
+ # Double-check domain (in case config changed since token was issued)
+ if not validate_email_domain(email):
+ flash("Your email is no longer authorized.", "error")
+ return redirect(url_for("auth.login"))
+
+ # Set session (shared contract across all auth providers)
+ name = email.split("@")[0].replace(".", " ").title()
+ session["user"] = {
+ "email": email,
+ "name": name,
+ "picture": "",
+ }
+
+ logger.info("User logged in via magic link: %s", email)
+ return redirect(url_for("dashboard"))
+
+
+# --- Provider class ---
+
+
+class EmailAuthProvider(AuthProvider):
+ """Email magic link authentication provider."""
+
+ def get_name(self) -> str:
+ return "email"
+
+ def get_display_name(self) -> str:
+ return "Email"
+
+ def get_blueprint(self) -> Blueprint:
+ return email_bp
+
+ def get_login_button(self) -> dict:
+ domain = Config.ALLOWED_DOMAIN
+ subtitle = f'For @{domain} email addresses.' if domain else ""
+ return {
+ "text": "Sign in with Email",
+ "url": "/login/email",
+ "icon_html": _EMAIL_ICON_HTML,
+ "subtitle": subtitle,
+ "order": 20,
+ "css_class": "btn-email",
+ "visible": True,
+ }
+
+ def is_available(self) -> bool:
+ """Available when allowed_domain is configured."""
+ return bool(Config.ALLOWED_DOMAIN)
+
+ def init_app(self, app) -> None:
+ """No additional initialization needed."""
+ pass
+
+
+# Module-level provider instance for auto-discovery
+provider = EmailAuthProvider()
diff --git a/config/instance.yaml.example b/config/instance.yaml.example
index be0e971..acf4c90 100644
--- a/config/instance.yaml.example
+++ b/config/instance.yaml.example
@@ -32,11 +32,15 @@ deployment:
branch: "main"
# --- Authentication ---
+# At minimum, set allowed_domain and webapp_secret_key.
+# Email magic link auth works out of the box (no external service needed).
+# Google OAuth is optional - add credentials to enable it.
auth:
- allowed_domain: "" # Google OAuth domain (e.g., "acme.com")
+ allowed_domain: "" # Email domain for login (e.g., "acme.com")
+ webapp_secret_key: "${WEBAPP_SECRET_KEY}"
+ # Optional: Google OAuth (if not set, only email magic link is available)
google_client_id: "${GOOGLE_CLIENT_ID}"
google_client_secret: "${GOOGLE_CLIENT_SECRET}"
- webapp_secret_key: "${WEBAPP_SECRET_KEY}"
# --- Data source ---
data_source:
@@ -46,11 +50,16 @@ data_source:
stack_url: "" # e.g., "https://connection.keboola.com"
project_id: ""
-# --- Email (optional, for password auth) ---
+# --- Email delivery (optional, for magic link auth) ---
+# Without SMTP, magic links are shown directly in browser (development mode).
+# For production, configure any SMTP relay (Gmail, Mailgun, SendGrid SMTP, etc.)
email:
from_address: "noreply@example.com"
from_name: "AI Data Analyst"
- sendgrid_api_key: "${SENDGRID_API_KEY}"
+ smtp_host: "${SMTP_HOST}" # e.g., "smtp.gmail.com"
+ smtp_port: 587 # 587 for STARTTLS, 465 for SSL
+ smtp_user: "${SMTP_USER}"
+ smtp_password: "${SMTP_PASSWORD}"
# --- Desktop app (optional) ---
desktop:
diff --git a/tests/test_email_auth.py b/tests/test_email_auth.py
new file mode 100644
index 0000000..af64098
--- /dev/null
+++ b/tests/test_email_auth.py
@@ -0,0 +1,95 @@
+"""Tests for the email magic link authentication provider."""
+
+import pytest
+
+from auth.email.provider import (
+ EmailAuthProvider,
+ _generate_magic_token,
+ _verify_magic_token,
+)
+
+
+# ---------------------------------------------------------------------------
+# Token tests
+# ---------------------------------------------------------------------------
+
+class TestMagicTokens:
+ """Test magic link token generation and verification."""
+
+ @pytest.fixture(autouse=True)
+ def setup_env(self, monkeypatch):
+ """Set required env vars for Config."""
+ monkeypatch.setenv("WEBAPP_SECRET_KEY", "test-secret-key-for-tokens")
+
+ def test_generate_and_verify_token(self):
+ """Valid token should return the email."""
+ token = _generate_magic_token("user@acme.com")
+ email = _verify_magic_token(token, max_age_seconds=60)
+ assert email == "user@acme.com"
+
+ def test_token_normalizes_email(self):
+ """Email should be lowercased in the token."""
+ token = _generate_magic_token("User@ACME.com")
+ email = _verify_magic_token(token, max_age_seconds=60)
+ assert email == "user@acme.com"
+
+ def test_expired_token_returns_none(self):
+ """Expired token should return None."""
+ import time
+ token = _generate_magic_token("user@acme.com")
+ time.sleep(2)
+ email = _verify_magic_token(token, max_age_seconds=1)
+ assert email is None
+
+ def test_invalid_token_returns_none(self):
+ """Tampered token should return None."""
+ email = _verify_magic_token("not-a-valid-token", max_age_seconds=60)
+ assert email is None
+
+ def test_empty_token_returns_none(self):
+ """Empty token should return None."""
+ email = _verify_magic_token("", max_age_seconds=60)
+ assert email is None
+
+
+# ---------------------------------------------------------------------------
+# Provider tests
+# ---------------------------------------------------------------------------
+
+class TestEmailAuthProvider:
+ """Test the EmailAuthProvider class."""
+
+ @pytest.fixture(autouse=True)
+ def setup_env(self, monkeypatch):
+ """Set required env vars."""
+ monkeypatch.setenv("WEBAPP_SECRET_KEY", "test-secret")
+
+ def test_provider_name(self):
+ provider = EmailAuthProvider()
+ assert provider.get_name() == "email"
+
+ def test_provider_display_name(self):
+ provider = EmailAuthProvider()
+ assert provider.get_display_name() == "Email"
+
+ def test_login_button_properties(self, monkeypatch):
+ # Reload config with allowed domain set
+ monkeypatch.setattr("webapp.config.Config.ALLOWED_DOMAIN", "acme.com")
+ provider = EmailAuthProvider()
+ button = provider.get_login_button()
+ assert button["text"] == "Sign in with Email"
+ assert button["url"] == "/login/email"
+ assert button["visible"] is True
+ assert button["order"] == 20
+ assert "btn-email" in button["css_class"]
+ assert "acme.com" in button["subtitle"]
+
+ def test_provider_available_with_domain(self, monkeypatch):
+ monkeypatch.setattr("webapp.config.Config.ALLOWED_DOMAIN", "acme.com")
+ provider = EmailAuthProvider()
+ assert provider.is_available() is True
+
+ def test_provider_unavailable_without_domain(self, monkeypatch):
+ monkeypatch.setattr("webapp.config.Config.ALLOWED_DOMAIN", "")
+ provider = EmailAuthProvider()
+ assert provider.is_available() is False
diff --git a/webapp/config.py b/webapp/config.py
index 47d1a26..24aea35 100644
--- a/webapp/config.py
+++ b/webapp/config.py
@@ -57,7 +57,17 @@ class Config:
os.environ.get("PASSWORD_USERS_FILE", "/data/auth/password_users.json")
)
- # SendGrid email service
+ # SMTP email delivery (for magic link auth)
+ SMTP_HOST = os.environ.get("SMTP_HOST", "")
+ SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
+ SMTP_USER = os.environ.get("SMTP_USER", "")
+ SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
+ SMTP_FROM = os.environ.get("SMTP_FROM",
+ os.environ.get("SMTP_USER",
+ _get(_instance, "email", "from_address", default="noreply@example.com")))
+ SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true"
+
+ # SendGrid email service (legacy, for password auth)
SENDGRID_API_KEY = os.environ.get("SENDGRID_API_KEY", "")
EMAIL_FROM_ADDRESS = os.environ.get("EMAIL_FROM_ADDRESS",
_get(_instance, "email", "from_address", default="noreply@example.com"))
diff --git a/webapp/static/style-custom.css b/webapp/static/style-custom.css
index 0f07743..3f9583a 100644
--- a/webapp/static/style-custom.css
+++ b/webapp/static/style-custom.css
@@ -1771,6 +1771,33 @@ a.slack-badge:hover {
transform: scale(0.98);
}
+/* Email magic link button styling */
+.login-page .btn-email {
+ background-color: #4361ee;
+ color: white;
+ border: 1px solid #3a56d4;
+ padding: 14px 28px;
+ font-size: var(--text-base);
+ font-weight: var(--font-medium);
+ border-radius: var(--radius-md);
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ transition: all 0.2s ease;
+ cursor: pointer;
+}
+
+.login-page .btn-email:hover {
+ background-color: #3a56d4;
+ box-shadow: 0 2px 8px rgba(67, 97, 238, 0.3);
+}
+
+.login-page .btn-email:active {
+ transform: scale(0.98);
+}
+
.login-page .btn-secondary {
background-color: transparent;
color: var(--text-secondary);
diff --git a/webapp/templates/login_magic_link.html b/webapp/templates/login_magic_link.html
new file mode 100644
index 0000000..1f0b7e3
--- /dev/null
+++ b/webapp/templates/login_magic_link.html
@@ -0,0 +1,59 @@
+{% extends "base_login.html" %}
+
+{% block title %}Sign in with Email - {{ config.INSTANCE_NAME }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
{{ config.INSTANCE_NAME }}
+
+ Enter your email address and we'll send you a sign-in link.
+
+
+
+
+
+
+
Sign In with Email
+
+ We'll send a magic link to your email. Click it to sign in — no password needed.
+
+
+
+
+ {% if allowed_domain %}
+
+ For @{{ allowed_domain }} email addresses.
+
+ {% endif %}
+
+
+ or
+
+
+
+ Back to Login
+
+
+
+
+
+{% endblock %}
diff --git a/webapp/templates/login_magic_link_sent.html b/webapp/templates/login_magic_link_sent.html
new file mode 100644
index 0000000..1de3cc3
--- /dev/null
+++ b/webapp/templates/login_magic_link_sent.html
@@ -0,0 +1,64 @@
+{% extends "base_login.html" %}
+
+{% block title %}Check Your Email - {{ config.INSTANCE_NAME }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
{{ config.INSTANCE_NAME }}
+
+
+
+
+
+ {% if console_mode %}
+
Development Mode
+
+ No SMTP server is configured. Use the link below to sign in.
+
+
+
+
+
+ To enable email delivery, configure SMTP settings in .env:
+ SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD
+
+
+ {% else %}
+
Check Your Email
+
+ We sent a sign-in link to {{ email }}.
+
+
+
+
+
+ Click the link in the email to sign in. The link expires in 15 minutes.
+
+
+ Don't see it? Check your spam folder.
+
+ {% endif %}
+
+
+ or
+
+
+
+ Try a different email
+
+
+
+
+
+{% endblock %}