feat(dev): LOCAL_DEV_MODE for one-command local dev + magic-link fixes (#32)

* feat(dev): add LOCAL_DEV_MODE for one-command local dev

When LOCAL_DEV_MODE=1, every protected route auto-authenticates as a seeded
admin user (default dev@localhost) — no login screen, no Google OAuth config,
no magic-link roundtrip. Startup logs a loud warning to make misuse obvious.

Also fixes two preexisting bugs in the magic-link flow that surfaced while
wiring up the dev fallback:

- /auth/email/verify only accepted POST, but the URL embedded in emails is
  a GET link — clicking from any mail client returned 405. Added a GET
  variant that consumes the token, sets the auth cookie, and redirects to
  /dashboard.
- Token expiry check compared an offset-aware datetime.now(timezone.utc)
  against an offset-naive value from DuckDB, raising TypeError on every
  valid link. Normalize the stored timestamp to UTC before subtracting.

Dev-only fallback (scoped strictly to LOCAL_DEV_MODE to keep test and
production behavior identical): send-link logs the magic link to stderr
and returns it as dev_link in the JSON response when no SMTP is configured.

Usage:
  ./scripts/run-local-dev.sh
  open http://localhost:8000  # lands on /dashboard as admin

* fix(dev): URL-encode magic-link email + avoid /login redirect loop

Two issues surfaced by Devin review on PR #32.

1. _build_magic_link interpolated email into the URL unescaped. For addresses
   with '+' (e.g. user+tag@gmail.com) Starlette's query parser decoded '+'
   as a space on the GET /verify side, so repo.get_by_email returned None
   and every click yielded 401 "Invalid link". quote(email, safe='') fixes
   both the email transport and the dev_link fallback.

2. /login in LOCAL_DEV_MODE unconditionally redirected to /dashboard. If
   dev-user seeding failed at startup (main.py wraps seed in try/except),
   /dashboard 401'd, the HTML redirect handler bounced to /login, and the
   loop repeated until the browser aborted. Now /login checks the dev user
   actually exists before short-circuiting; otherwise it falls through to
   the normal login form so the missing seed is visible.
This commit is contained in:
Petr Simecek 2026-04-22 14:47:33 +02:00 committed by GitHub
parent d2c76cb221
commit 9b5214ea6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 219 additions and 33 deletions

View file

@ -1,5 +1,7 @@
"""FastAPI auth dependencies — current user, role checking."""
import logging
import os
from typing import Optional
import duckdb
@ -10,6 +12,21 @@ from src.db import get_system_db
from src.rbac import Role, ROLE_HIERARCHY
from src.repositories.users import UserRepository
logger = logging.getLogger(__name__)
# Default dev user used when LOCAL_DEV_MODE=1. Seeded at startup by app/main.py.
LOCAL_DEV_DEFAULT_EMAIL = "dev@localhost"
def is_local_dev_mode() -> bool:
"""True when LOCAL_DEV_MODE=1 — unsafe for production, bypasses auth."""
return os.environ.get("LOCAL_DEV_MODE", "").lower() in ("1", "true", "yes")
def get_local_dev_email() -> str:
"""Email of the auto-logged-in dev user. Configurable via LOCAL_DEV_USER_EMAIL."""
return os.environ.get("LOCAL_DEV_USER_EMAIL", LOCAL_DEV_DEFAULT_EMAIL)
def _get_db():
conn = get_system_db()
@ -39,12 +56,30 @@ def _client_ip(request: Optional[Request]) -> Optional[str]:
return getattr(client, "host", None) if client else None
def _get_local_dev_user(conn: duckdb.DuckDBPyConnection) -> Optional[dict]:
"""Return the seeded dev user when LOCAL_DEV_MODE is on, else None."""
repo = UserRepository(conn)
user = repo.get_by_email(get_local_dev_email())
if not user:
logger.error(
"LOCAL_DEV_MODE is on but dev user %s is not seeded; expected app startup to seed it",
get_local_dev_email(),
)
return user
async def get_current_user(
request: Request = None,
authorization: Optional[str] = Header(None),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
) -> dict:
"""Extract and validate JWT from Authorization header or cookie. Returns user dict."""
if is_local_dev_mode():
user = _get_local_dev_user(conn)
if user:
return user
# Fall through to normal auth if seed missing — surfaces the bug instead of hiding it.
token = None
# Try Authorization header first

View file

@ -4,13 +4,15 @@ import logging
import os
import secrets
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
import duckdb
from app.auth.jwt import create_access_token
from app.auth.dependencies import _get_db
from app.auth.dependencies import _get_db, is_local_dev_mode
from src.repositories.users import UserRepository
logger = logging.getLogger(__name__)
@ -29,15 +31,36 @@ class MagicLinkVerify(BaseModel):
def is_available() -> bool:
# In dev mode the link is rendered to logs + response, so the provider is "available"
# even without SMTP/SendGrid. Keeps the login UI showing the magic-link option.
if is_local_dev_mode():
return True
return bool(os.environ.get("SMTP_HOST") or os.environ.get("SENDGRID_API_KEY"))
def _has_email_transport() -> bool:
return bool(os.environ.get("SMTP_HOST") or os.environ.get("SENDGRID_API_KEY"))
def _build_magic_link(email: str, token: str) -> str:
# URL-encode email: a literal '+' in a query string decodes to space per
# application/x-www-form-urlencoded, which would break addresses like
# "user+tag@gmail.com" on the GET /verify side.
server_url = os.environ.get("SERVER_URL", "http://localhost:8000")
return f"{server_url}/auth/email/verify?email={quote(email, safe='')}&token={token}"
@router.post("/send-link")
async def send_magic_link(
request: MagicLinkRequest,
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Send a magic link to the user's email."""
"""Send a magic link to the user's email.
When SMTP/SendGrid is not configured, or LOCAL_DEV_MODE=1, the link is
logged to stderr and returned in the response body so a developer can
click it without an email transport.
"""
repo = UserRepository(conn)
user = repo.get_by_email(request.email)
@ -53,57 +76,106 @@ async def send_magic_link(
reset_token_created=datetime.now(timezone.utc),
)
# Send email (best effort)
try:
_send_email(request.email, token)
except Exception as e:
logger.error(f"Failed to send magic link email: {e}")
link = _build_magic_link(request.email, token)
send_error: str | None = None
if _has_email_transport():
try:
_send_email(request.email, token)
except Exception as e:
send_error = str(e)
logger.error("Failed to send magic link email to %s: %s", request.email, e)
# Dev fallback: expose the link in logs + response so you can click it without SMTP.
# Scoped strictly to LOCAL_DEV_MODE so test and production behavior are unchanged.
if is_local_dev_mode():
logger.warning("=" * 60)
logger.warning("Magic link for %s (LOCAL_DEV_MODE fallback):", request.email)
logger.warning(" %s", link)
logger.warning("=" * 60)
response: dict = {
"message": "Magic link generated (LOCAL_DEV_MODE) — click dev_link to log in.",
"dev_link": link,
}
if send_error:
response["send_error"] = send_error
return response
return {"message": "If this email is registered, you will receive a login link."}
def _consume_token(conn: duckdb.DuckDBPyConnection, email: str, token: str) -> dict:
"""Validate & consume a magic-link token. Returns the user dict or raises 401."""
repo = UserRepository(conn)
user = repo.get_by_email(email)
if not user:
raise HTTPException(status_code=401, detail="Invalid link")
if user.get("reset_token") != token:
raise HTTPException(status_code=401, detail="Invalid or expired link")
created = user.get("reset_token_created")
if created:
if isinstance(created, str):
created = datetime.fromisoformat(created)
# DuckDB returns TIMESTAMP as offset-naive; we stored it as UTC, so assume UTC.
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
if (datetime.now(timezone.utc) - created).total_seconds() > MAGIC_LINK_EXPIRY:
raise HTTPException(status_code=401, detail="Link expired")
# Clear token (one-time use)
repo.update(id=user["id"], reset_token=None, reset_token_created=None)
return user
@router.post("/verify")
async def verify_magic_link(
request: MagicLinkVerify,
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Verify a magic link token and issue JWT."""
repo = UserRepository(conn)
user = repo.get_by_email(request.email)
if not user:
raise HTTPException(status_code=401, detail="Invalid link")
if user.get("reset_token") != request.token:
raise HTTPException(status_code=401, detail="Invalid or expired link")
# Check expiry
created = user.get("reset_token_created")
if created:
if isinstance(created, str):
created = datetime.fromisoformat(created)
if (datetime.now(timezone.utc) - created).total_seconds() > MAGIC_LINK_EXPIRY:
raise HTTPException(status_code=401, detail="Link expired")
# Clear token (one-time use)
repo.update(id=user["id"], reset_token=None, reset_token_created=None)
"""Verify a magic link token and issue JWT (JSON API for programmatic clients)."""
user = _consume_token(conn, request.email, request.token)
jwt_token = create_access_token(user["id"], user["email"], user["role"])
return {"access_token": jwt_token, "token_type": "bearer", "email": user["email"], "role": user["role"]}
@router.get("/verify")
async def verify_magic_link_get(
email: str,
token: str,
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Click-through variant — verifies token, sets cookie, redirects to /dashboard.
This is the URL we embed in outgoing emails (and the dev-fallback link), so
clicking it in a mail client logs the user in without a separate API call.
"""
user = _consume_token(conn, email, token)
jwt_token = create_access_token(user["id"], user["email"], user["role"])
# secure=False when DOMAIN is unset so the cookie is actually sent on plain HTTP (dev).
use_secure = os.environ.get("DOMAIN", "") != ""
response = RedirectResponse(url="/dashboard", status_code=302)
response.set_cookie(
key="access_token", value=jwt_token,
httponly=True, max_age=86400, samesite="lax",
secure=use_secure,
)
return response
def _send_email(email: str, token: str):
"""Send magic link email via SMTP or SendGrid."""
link = _build_magic_link(email, token)
sendgrid_key = os.environ.get("SENDGRID_API_KEY")
if sendgrid_key:
import sendgrid
from sendgrid.helpers.mail import Mail
sg = sendgrid.SendGridAPIClient(api_key=sendgrid_key)
server_url = os.environ.get("SERVER_URL", "http://localhost:8000")
message = Mail(
from_email=os.environ.get("EMAIL_FROM_ADDRESS", "noreply@example.com"),
to_emails=email,
subject="Login Link",
html_content=f'<p>Click to login: <a href="{server_url}/auth/email/verify?email={email}&token={token}">Login</a></p>',
html_content=f'<p>Click to login: <a href="{link}">Login</a></p>',
)
sg.send(message)
return
@ -112,8 +184,7 @@ def _send_email(email: str, token: str):
if smtp_host:
import smtplib
from email.mime.text import MIMEText
server_url = os.environ.get("SERVER_URL", "http://localhost:8000")
msg = MIMEText(f"Login link: {server_url}/auth/email/verify?email={email}&token={token}")
msg = MIMEText(f"Login link: {link}")
msg["Subject"] = "Login Link"
msg["From"] = os.environ.get("SMTP_FROM", "noreply@example.com")
msg["To"] = email

View file

@ -95,11 +95,21 @@ def create_app() -> FastAPI:
SCHEMA_VERSION,
)
# Seed admin user for testing/CI (when SEED_ADMIN_EMAIL is set).
# LOCAL_DEV_MODE: bypass authentication for local development. DO NOT enable in prod.
# When on, every protected route auto-logs in as a seeded admin user (default dev@localhost).
from app.auth.dependencies import is_local_dev_mode, get_local_dev_email
if is_local_dev_mode():
logger.warning("=" * 60)
logger.warning("LOCAL_DEV_MODE is ON — authentication is bypassed.")
logger.warning("All requests auto-authenticate as: %s", get_local_dev_email())
logger.warning("NEVER enable this in a deployment reachable from the internet.")
logger.warning("=" * 60)
# Seed admin user for testing/CI (when SEED_ADMIN_EMAIL is set) OR for local dev.
# Optional: SEED_ADMIN_PASSWORD sets password_hash on first seed so the user
# can log in immediately without bootstrap. Only applied if the user has no
# password_hash yet — never overwrites an existing password.
seed_email = os.environ.get("SEED_ADMIN_EMAIL")
seed_email = os.environ.get("SEED_ADMIN_EMAIL") or (get_local_dev_email() if is_local_dev_mode() else None)
if seed_email:
try:
from src.db import get_system_db

View file

@ -201,6 +201,19 @@ async def setup_wizard(request: Request, conn: duckdb.DuckDBPyConnection = Depen
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
from app.auth.dependencies import is_local_dev_mode, _get_local_dev_user
if is_local_dev_mode():
# Only short-circuit to /dashboard if the dev user is actually seeded.
# Otherwise the 401 from /dashboard would bounce back to /login and loop.
from src.db import get_system_db
conn = get_system_db()
try:
if _get_local_dev_user(conn):
return RedirectResponse(url="/dashboard", status_code=302)
finally:
conn.close()
# Fall through to the normal login form so the missing-seed error is visible.
next_path = request.query_params.get("next", "")
if not next_path.startswith("/") or next_path.startswith("//"):
next_path = ""

View file

@ -0,0 +1,32 @@
# Local-dev overlay — auth bypass + no real email delivery required.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.local-dev.yml up
# or:
# scripts/run-local-dev.sh
#
# Effect:
# - LOCAL_DEV_MODE=1 makes every protected route auto-login as dev@localhost (role=admin).
# - Magic-link emails are logged to stderr and returned in the response (no SMTP needed).
# - Secrets (JWT, session) auto-generate into /data/state on first boot.
# - No .env file required; nothing else is mandatory.
#
# NEVER deploy this overlay to an environment reachable from the internet.
services:
app:
env_file: [] # Drop the project .env requirement — everything dev-relevant is inline below.
environment:
- DATA_DIR=/data
- LOCAL_DEV_MODE=1
- LOCAL_DEV_USER_EMAIL=dev@localhost
- SERVER_URL=http://localhost:8000
- LOG_LEVEL=info
# Scheduler also reads LOCAL_DEV flag so its auto-auth uses the same seed user.
scheduler:
env_file: []
environment:
- DATA_DIR=/data
- API_URL=http://app:8000
- LOCAL_DEV_MODE=1
- SEED_ADMIN_EMAIL=dev@localhost

25
scripts/run-local-dev.sh Executable file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Run Agnes locally with auth bypass + dev-mode magic links.
#
# Stacks three compose files:
# 1. docker-compose.yml — base services
# 2. docker-compose.override.yml — hot-reload + source bind mount (dev default)
# 3. docker-compose.local-dev.yml — LOCAL_DEV_MODE=1, drops .env requirement
#
# After startup visit http://localhost:8000 — you'll land on /dashboard
# logged in as dev@localhost (role=admin). No login screen, no email delivery needed.
set -euo pipefail
cd "$(dirname "$0")/.."
# Ensure docker-compose.yml does not require a .env file. We override env_file in the
# local-dev overlay, but compose still touches the file path during config validation.
if [[ ! -f .env ]]; then
touch .env
fi
exec docker compose \
-f docker-compose.yml \
-f docker-compose.override.yml \
-f docker-compose.local-dev.yml \
up "$@"