diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index 421c7ff..c867e5e 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -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 diff --git a/app/auth/providers/email.py b/app/auth/providers/email.py index ff112d5..a0e57d4 100644 --- a/app/auth/providers/email.py +++ b/app/auth/providers/email.py @@ -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'

Click to login: Login

', + html_content=f'

Click to login: Login

', ) 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 diff --git a/app/main.py b/app/main.py index dd92196..32ee07b 100644 --- a/app/main.py +++ b/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 diff --git a/app/web/router.py b/app/web/router.py index 84d0e0a..146d347 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -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 = "" diff --git a/docker-compose.local-dev.yml b/docker-compose.local-dev.yml new file mode 100644 index 0000000..f4425bd --- /dev/null +++ b/docker-compose.local-dev.yml @@ -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 diff --git a/scripts/run-local-dev.sh b/scripts/run-local-dev.sh new file mode 100755 index 0000000..dcdc861 --- /dev/null +++ b/scripts/run-local-dev.sh @@ -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 "$@"