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:
parent
d2c76cb221
commit
9b5214ea6f
6 changed files with 219 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
app/main.py
14
app/main.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
32
docker-compose.local-dev.yml
Normal file
32
docker-compose.local-dev.yml
Normal 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
25
scripts/run-local-dev.sh
Executable 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 "$@"
|
||||
Loading…
Reference in a new issue