Add local auth: AuthProvider, mailer, sessions, /api/v1/auth

Pluggable AuthProvider interface with a local (email+password) implementation, and a Mailer interface (ConsoleMailer for dev, SMTPMailer for operators). The auth service owns registration, login, opaque session issuance, email verification, and password reset (which revokes prior sessions). Endpoints under /api/v1/auth; sessions are returned as a Bearer token and set as an HttpOnly cookie.

Replaces the temporary X-User-Id shim: get_current_user now resolves a real session (Bearer or cookie). The open user-bootstrap endpoint is gone (registration replaces it). App logging is configured so the ConsoleMailer's verification/reset links are visible to self-hosters. Verified end-to-end on the deploy target, including the email-verification flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
2026-06-06 10:51:51 -04:00
parent 5123c85397
commit 00bfe8bfca
15 changed files with 497 additions and 31 deletions
+32 -17
View File
@@ -1,33 +1,48 @@
"""Shared API dependencies."""
"""Shared API dependencies: DB session, the authenticated user, and the mailer."""
import uuid
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.db import get_session
from app.integrations.mailer.base import Mailer
from app.integrations.mailer.console import ConsoleMailer
from app.integrations.mailer.smtp import SMTPMailer
from app.models.user import User
from app.services.user_service import get_user
from app.services import auth_service
SessionDep = Annotated[AsyncSession, Depends(get_session)]
async def get_current_user(
session: SessionDep,
x_user_id: Annotated[uuid.UUID | None, Header()] = None,
) -> User:
"""TEMPORARY pre-auth shim: identifies the caller via the ``X-User-Id``
header. Replaced by the AuthProvider (sessions/tokens) in the auth slice.
The assistant principal will also be minted here, scoped to its user."""
if x_user_id is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, "X-User-Id header required (pre-auth)"
)
user = await get_user(session, x_user_id)
def extract_session_token(request: Request) -> str | None:
"""Bearer header (API clients) takes precedence over the session cookie
(browser)."""
authorization = request.headers.get("authorization")
if authorization and authorization.lower().startswith("bearer "):
return authorization[7:].strip()
return request.cookies.get(get_settings().cookie_name)
async def get_current_user(request: Request, session: SessionDep) -> User:
raw_token = extract_session_token(request)
if raw_token is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "authentication required")
user = await auth_service.resolve_session_user(session, raw_token=raw_token)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "unknown user")
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid or expired session")
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
def get_mailer() -> Mailer:
settings = get_settings()
if settings.mailer == "smtp" and settings.smtp_host:
return SMTPMailer(settings)
return ConsoleMailer()
MailerDep = Annotated[Mailer, Depends(get_mailer)]