"""Authentication router. ``POST /api/auth/login`` — verify master password and issue a session. ``POST /api/auth/logout`` — revoke the current session. The session token is returned both in the JSON body (for API-first consumers) and as an ``HttpOnly`` cookie (for the browser SPA). """ from __future__ import annotations import structlog from fastapi import APIRouter, HTTPException, Request, Response, status from app.dependencies import AuthDep, DbDep, SessionCacheDep, SessionRepoDep, SettingsDep from app.models.auth import LoginRequest, LoginResponse, LogoutResponse from app.services import auth_service from app.utils.constants import SESSION_COOKIE_NAME log: structlog.stdlib.BoundLogger = structlog.get_logger() router = APIRouter(prefix="/api/auth", tags=["auth"]) @router.post( "/login", response_model=LoginResponse, summary="Authenticate with the master password", ) async def login( body: LoginRequest, response: Response, db: DbDep, settings: SettingsDep, session_repo: SessionRepoDep, ) -> LoginResponse: """Verify the master password and return a session token. On success the token is also set as an ``HttpOnly`` ``SameSite=Lax`` cookie so the browser SPA benefits from automatic credential handling. Args: body: Login request validated by Pydantic. response: FastAPI response object used to set the cookie. db: Injected aiosqlite connection. settings: Application settings (used for session duration). Returns: :class:`~app.models.auth.LoginResponse` containing the token. Raises: HTTPException: 401 if the password is incorrect. """ try: signed_token, expires_at = await auth_service.login( db, password=body.password, session_duration_minutes=settings.session_duration_minutes, session_secret=settings.session_secret, session_repo=session_repo, ) except ValueError as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc), ) from exc response.set_cookie( key=SESSION_COOKIE_NAME, value=signed_token, httponly=settings.session_cookie_httponly, samesite=settings.session_cookie_samesite, secure=settings.session_cookie_secure, max_age=settings.session_duration_minutes * 60, ) return LoginResponse(token=signed_token, expires_at=expires_at) @router.get( "/session", summary="Validate the current session", ) async def validate_session( _: AuthDep, ) -> dict[str, bool]: """Validate the current session. This endpoint requires a valid session and returns 200 if the session is valid and still active. If the session is invalid, expired, or missing, FastAPI's ``require_auth`` dependency returns 401 automatically. The frontend calls this on mount to bootstrap its authentication state from the backend rather than relying solely on cached ``sessionStorage``. Args: _: The injected session object (unused, but its presence triggers validation). Returns: A simple JSON object confirming the session is valid. """ return {"valid": True} @router.post( "/logout", response_model=LogoutResponse, summary="Revoke the current session", ) async def logout( request: Request, response: Response, db: DbDep, settings: SettingsDep, session_cache: SessionCacheDep, session_repo: SessionRepoDep, ) -> LogoutResponse: """Invalidate the active session. The session token is read from the ``bangui_session`` cookie or the ``Authorization: Bearer`` header. If no token is present the request is silently treated as a successful logout (idempotent). Args: request: FastAPI request (used to extract the token). response: FastAPI response (used to clear the cookie). db: Injected aiosqlite connection. settings: Application settings (used to unwrap signed tokens). Returns: :class:`~app.models.auth.LogoutResponse`. """ token = _extract_token(request) if token: raw_token = await auth_service.logout( db, token, settings.session_secret, session_repo=session_repo, ) if raw_token: session_cache.invalidate(raw_token) session_cache.invalidate(token) response.delete_cookie(key=SESSION_COOKIE_NAME) return LogoutResponse() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _extract_token(request: Request) -> str | None: """Extract the session token from cookie or Authorization header. Args: request: The incoming FastAPI request. Returns: The token string, or ``None`` if absent. """ token: str | None = request.cookies.get(SESSION_COOKIE_NAME) if token: return token auth_header: str = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): return auth_header[len("Bearer "):] return None