"""``/auth/*`` endpoints — login, logout, me. Wire spec lives in CONTRACT.md §3 + BACKEND_BUILD.md §6.2. Highlights: - Cookie name: ``session``. Opaque token. ``HttpOnly``, ``SameSite=Lax``, ``Secure=False`` in dev (HTTP is fine when same-origin via the Express proxy), ``Path=/``, ``Max-Age=2592000`` (30 days — matches the row's ``expires_at``). - ``POST /auth/login`` → 200 + ``MeResponse`` body + ``Set-Cookie``. Bad creds return 401 with the canonical ``UNAUTHENTICATED`` envelope. - ``POST /auth/logout`` → 204 (no body) + cookie cleared. - ``GET /auth/me`` → 200 + ``MeResponse``, or 401 ``UNAUTHENTICATED``. The CSRF middleware (:mod:`src.api.middleware.csrf`) explicitly exempts ``/auth/login`` because the request runs before the session exists. The other two mutating routes (``logout``, future-others) require the ``X-Requested-With`` header the FE fetch wrapper always sets. """ from __future__ import annotations import os from fastapi import APIRouter, Cookie, Depends, Request, Response, status from src.api.auth.sessions import create_session, invalidate_session from src.api.limits import enforce_login_attempt from src.api.deps import current_user from src.api.dto.auth import LoginRequest, MeResponse from src.api.errors import Unauthenticated from src.lib.auth.models import User from src.lib.auth.repo import authenticate router = APIRouter(prefix="/auth", tags=["auth"]) # Cookie shape — CONTRACT.md §3. The Max-Age in seconds (30 days) matches # the sessions row's expires_at TTL set in src.api.auth.sessions. _COOKIE_NAME = "session" _COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days _COOKIE_PATH = "/" def _cookie_secure() -> bool: """Set the ``Secure`` cookie flag on the cloud (all-HTTPS) deployment. In cloud the browser reaches the app over HTTPS (Vercel → HF Space), so ``Secure`` is correct and prevents the session cookie from ever being sent over plain HTTP. Local dev is same-origin HTTP through the Vite proxy, where ``Secure`` would cause the browser to drop the cookie — so it's off unless ``RUNTIME=cloud``. """ return os.environ.get("RUNTIME", "").strip().lower() == "cloud" def _to_me(user: User) -> MeResponse: """Adapt the lib-side :class:`User` dataclass to the wire DTO. The User dataclass carries the password field for in-process auth checks; we never put it on the wire. ``MeResponse`` projects only the three fields the frontend needs to render the header badge and gate admin-only UI. """ return MeResponse( username=user.username, is_admin=user.is_admin, role=user.role, display_name=user.display_name, ) @router.post("/login", response_model=MeResponse) def login(payload: LoginRequest, request: Request, response: Response) -> MeResponse: """Validate credentials, mint a session, set the cookie, return MeResponse. Bad credentials and unknown usernames both return 401 ``UNAUTHENTICATED`` with a generic message — leaking "user exists but password wrong" gives an attacker a username oracle, which we don't want even on a 3-account system because it normalizes the wrong behavior. """ # Brute-force throttle BEFORE the credential check, keyed by both the # submitted username and the client IP, so neither a single-account guess # loop nor a username-spray from one host runs unbounded. Raises 429. enforce_login_attempt(f"user:{(payload.username or '').strip().lower()}") enforce_login_attempt(f"ip:{request.client.host if request.client else 'unknown'}") user = authenticate(payload.username, payload.password) if user is None: # Same envelope as a missing/expired session — the FE shows the # message in-form, so the wording is the user-visible string. raise Unauthenticated("Wrong username or password.") token = create_session(user.username) response.set_cookie( key=_COOKIE_NAME, value=token, max_age=_COOKIE_MAX_AGE, httponly=True, secure=_cookie_secure(), # Secure on cloud (HTTPS); off for local HTTP samesite="lax", path=_COOKIE_PATH, ) return _to_me(user) @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) def logout( response: Response, session: str | None = Cookie(default=None), ) -> Response: """Invalidate the current session (if any) and clear the cookie. Idempotent — logging out twice or logging out without ever having logged in both return 204. The CSRF middleware requires ``X-Requested-With`` on this route (it's not in the exemption list); a missing header surfaces as 403 ``FORBIDDEN`` before the handler runs. """ invalidate_session(session or "") # Path/samesite/secure must match the cookie that was set, otherwise # the browser ignores the clear directive on some implementations. response.delete_cookie( key=_COOKIE_NAME, path=_COOKIE_PATH, samesite="lax", secure=_cookie_secure(), httponly=True, ) response.status_code = status.HTTP_204_NO_CONTENT return response @router.get("/me", response_model=MeResponse) def me(user: User = Depends(current_user)) -> MeResponse: """Return the current user. 401 ``UNAUTHENTICATED`` when not logged in. The 401 is raised inside :func:`current_user` when the cookie is missing/expired/unknown — this handler only ever sees a valid user. """ return _to_me(user)