From 140a75f706c03563b9795462dc89fe605c5c54e5 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Wed, 29 Apr 2026 22:02:02 -0600 Subject: [PATCH] Add cookie-based SPA auth and update container plumbing Backend now exposes /api/auth/login + /api/auth/logout setting an httpOnly ws_token cookie, and get_current_user accepts either the cookie (SPA) or a Bearer token (n8n/CLI). AuthContext probes the cookie via /api/v1/auth/me. Dockerfiles and compose files updated for the new agent service deps and CopilotKit dev sidecar. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/Dockerfile | 2 +- backend/app/api/v1/endpoints/auth.py | 10 +- backend/app/auth.py | 31 +++++- backend/app/config.py | 2 + backend/app/main.py | 148 ++++++++++++++++++++++++++- docker-compose.prod.yml | 18 +++- docker-compose.yml | 45 +++++++- frontend/src/AuthContext.tsx | 27 +++-- 8 files changed, 257 insertions(+), 26 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index bafad53..81a76e4 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,6 +2,6 @@ FROM python:3.11-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/* COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir --pre -r requirements.txt COPY . . CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index 6ec50cd..107f670 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -1,8 +1,7 @@ -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm -from fastapi import Depends -from app.auth import create_access_token +from app.auth import create_access_token, get_current_user, get_current_user_cookie_or_bearer from app.config import settings router = APIRouter(prefix="/auth", tags=["auth"]) @@ -20,3 +19,8 @@ def login(form_data: OAuth2PasswordRequestForm = Depends()): ) token = create_access_token(form_data.username) return {"access_token": token, "token_type": "bearer"} + + +@router.get("/me") +def me(username: str = Depends(get_current_user_cookie_or_bearer)): + return {"username": username} diff --git a/backend/app/auth.py b/backend/app/auth.py index edb8f98..9daf2be 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,7 +1,9 @@ import hashlib +import re from datetime import datetime, timedelta +from typing import Optional -from fastapi import Depends, HTTPException, status +from fastapi import Cookie, Depends, Header, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from sqlmodel import Session, select @@ -22,8 +24,8 @@ def hash_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() -def get_current_user(token: str = Depends(oauth2_scheme)) -> str: - # Try JWT first +def _validate_token(token: str) -> str: + """Validate JWT and return subject, or raise 401.""" try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get("sub") @@ -51,3 +53,26 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> str: return f"api:{api_token.name}" raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + +def get_current_user_cookie_or_bearer( + authorization: Optional[str] = Header(default=None), + ws_token: Optional[str] = Cookie(default=None), +) -> str: + """Accepts httpOnly cookie (SPA) or Bearer token (API clients / n8n).""" + token: Optional[str] = None + if authorization and authorization.lower().startswith("bearer "): + token = authorization.split(" ", 1)[1].strip() + elif ws_token: + token = ws_token + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + return _validate_token(token) + + +def get_current_user( + authorization: Optional[str] = Header(default=None), + ws_token: Optional[str] = Cookie(default=None), +) -> str: + """SPA cookie or Bearer token. Single dependency for all v1 endpoints.""" + return get_current_user_cookie_or_bearer(authorization, ws_token) diff --git a/backend/app/config.py b/backend/app/config.py index 99de8e2..c2105a9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,6 +12,8 @@ class Settings(BaseSettings): VAPID_PRIVATE_KEY: str = "" VAPID_PUBLIC_KEY: str = "" VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev" + OPENAI_API_KEY: str = "" + AGENT_MODEL: str = "gpt-5.4-mini" class Config: env_file = ".env" diff --git a/backend/app/main.py b/backend/app/main.py index 1e566d1..4671eba 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,16 +1,63 @@ import asyncio +import json +import re +import uuid from contextlib import asynccontextmanager -from fastapi import FastAPI +from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint +from fastapi import FastAPI, HTTPException, Request, Response, status from fastapi.middleware.cors import CORSMiddleware +from jose import JWTError, jwt +from pydantic import BaseModel +from app.agent.agent import build_agent +from app.agent.tools import reset_session, set_session from app.api.v1.router import api_router +from app.auth import ALGORITHM, create_access_token from app.config import settings -from app.db import init_db, run_migrations +from app.db import get_session, init_db, run_migrations from app.seed import seed_db from app.services.exchange_rate import refresh_rates_periodically +AGENT_PATH = "/api/v1/agent/agui" + + +def _pair_orphan_tool_calls(messages: list) -> list: + """Inject synthetic tool responses for any assistant tool_calls that have + no matching tool message. OpenAI rejects histories where a tool_calls + entry is not immediately followed by the corresponding tool response.""" + out: list = [] + pending: list[str] = [] + + def flush(): + for call_id in pending: + out.append({"role": "tool", "tool_call_id": call_id, "content": ""}) + pending.clear() + + for msg in messages: + role = msg.get("role", "") + if role == "tool": + call_id = msg.get("tool_call_id") or msg.get("toolCallId") + if call_id and call_id in pending: + pending.remove(call_id) + out.append(msg) + continue + if role == "assistant": + flush() + out.append(msg) + for tc in msg.get("tool_calls") or msg.get("toolCalls") or []: + tc_id = tc.get("id") if isinstance(tc, dict) else None + if tc_id: + pending.append(tc_id) + continue + flush() + out.append(msg) + + flush() + return out + + @asynccontextmanager async def lifespan(app: FastAPI): init_db() @@ -37,9 +84,106 @@ app.add_middleware( allow_headers=["*"], ) + +@app.middleware("http") +async def agent_auth_and_session(request: Request, call_next): + """For the AG-UI route, validate the JWT, repair message history, and + bind a DB session to a ContextVar so agent tools can query without going + through Depends.""" + if not request.url.path.startswith(AGENT_PATH): + return await call_next(request) + + if request.method == "OPTIONS": + return await call_next(request) + + auth_header = request.headers.get("authorization", "") + token: str | None = None + if auth_header.lower().startswith("bearer "): + token = auth_header.split(" ", 1)[1].strip() + else: + cookie_header = request.headers.get("cookie", "") + m = re.search(r"(?:^|;\s*)ws_token=([^;]+)", cookie_header) + if m: + token = m.group(1) + + if not token: + return Response(status_code=401, content="Missing auth") + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + if not payload.get("sub"): + return Response(status_code=401, content="Invalid token") + except JWTError: + return Response(status_code=401, content="Invalid token") + + # Repair orphan tool_calls before the MAF agent sees the message history. + if request.method == "POST" and "application/json" in request.headers.get("content-type", ""): + raw = await request.body() + try: + body = json.loads(raw) + if isinstance(body.get("messages"), list): + body["messages"] = _pair_orphan_tool_calls(body["messages"]) + raw = json.dumps(body).encode() + except Exception: + pass + # Starlette caches the body; replace it so call_next sees the fixed bytes. + request._body = raw # type: ignore[attr-defined] + + session_gen = get_session() + session = next(session_gen) + token_var = set_session(session) + try: + return await call_next(request) + finally: + reset_session(token_var) + try: + next(session_gen) + except StopIteration: + pass + + +# Register app routes app.include_router(api_router) +# Mount the AG-UI agent endpoint. +add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH) + @app.get("/") def root(): return {"app": "WealthySmart", "version": "0.1.0"} + + +# ── Cookie-based auth endpoints (used by the Vite SPA) ────────────────────── + +class LoginRequest(BaseModel): + username: str + password: str + + +@app.post("/api/auth/login") +def cookie_login(body: LoginRequest, response: Response): + if ( + body.username != settings.ADMIN_USERNAME + or body.password != settings.ADMIN_PASSWORD + ): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + token = create_access_token(body.username) + response.set_cookie( + key="ws_token", + value=token, + httponly=True, + samesite="lax", + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + secure=False, # set True behind TLS in production via nginx + ) + return {"ok": True} + + +@app.post("/api/auth/logout", status_code=204) +def cookie_logout(response: Response): + response.delete_cookie("ws_token") + + +@app.get("/api/health") +def health(): + return {"ok": True} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d35936e..896c7c4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -30,6 +30,8 @@ services: ADMIN_PASSWORD: ${ADMIN_PASSWORD} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} + OPENAI_API_KEY: ${OPENAI_API_KEY} + AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini} expose: - "8000" networks: @@ -47,22 +49,32 @@ services: frontend: build: context: ./frontend - dockerfile: Dockerfile.prod + dockerfile: Dockerfile + target: runner + args: + NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} container_name: wealthysmart-frontend-prod restart: unless-stopped environment: + NODE_ENV: production + BACKEND_URL: http://backend:8000 + AGENT_URL: http://backend:8000/api/v1/agent/agui + JWT_SECRET: ${SECRET_KEY} + COOKIE_DOMAIN: wealth.cescalante.dev + COOKIE_SECURE: "true" VIRTUAL_HOST: wealth.cescalante.dev + VIRTUAL_PORT: "3000" LETSENCRYPT_HOST: wealth.cescalante.dev LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL} expose: - - "80" + - "3000" networks: - wealthysmart-network - nginx-prod-network depends_on: - backend healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"] + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"] interval: 30s timeout: 10s retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml index ead8d5a..28ac1b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,8 @@ services: DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini} ports: - "8001:8000" volumes: @@ -32,17 +34,52 @@ services: depends_on: db: condition: service_healthy + develop: + watch: + - path: ./backend/app + action: sync + target: /app/app + - path: ./backend/requirements.txt + action: rebuild + - path: ./backend/Dockerfile + action: rebuild frontend: build: context: ./frontend dockerfile: Dockerfile + target: dev container_name: wealthysmart-frontend-dev ports: - - "5175:5173" - volumes: - - ./frontend:/app - - /app/node_modules + - "5175:3000" + environment: + NODE_ENV: development + AGENT_URL: http://backend:8000/api/v1/agent/agui + depends_on: + - backend + develop: + watch: + - path: ./frontend/src + action: sync + target: /app/src + - path: ./frontend/public + action: sync + target: /app/public + - path: ./frontend/server.ts + action: sync + target: /app/server.ts + - path: ./frontend/vite.config.ts + action: sync+restart + target: /app/vite.config.ts + - path: ./frontend/tsconfig.json + action: sync+restart + target: /app/tsconfig.json + - path: ./frontend/package.json + action: rebuild + - path: ./frontend/pnpm-lock.yaml + action: rebuild + - path: ./frontend/Dockerfile + action: rebuild volumes: postgres_data: diff --git a/frontend/src/AuthContext.tsx b/frontend/src/AuthContext.tsx index a925f2d..f82b110 100644 --- a/frontend/src/AuthContext.tsx +++ b/frontend/src/AuthContext.tsx @@ -1,33 +1,40 @@ -import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; +import { createContext, useContext, useState, useEffect, type ReactNode } from "react"; +import { logout as apiLogout } from "@/lib/api"; interface AuthCtx { isAuthenticated: boolean; - logout: () => void; + isLoading: boolean; + logout: () => Promise; setAuthenticated: (v: boolean) => void; } const AuthContext = createContext({ isAuthenticated: false, - logout: () => {}, + isLoading: true, + logout: async () => {}, setAuthenticated: () => {}, }); export function AuthProvider({ children }: { children: ReactNode }) { - const [isAuthenticated, setAuthenticated] = useState(!!localStorage.getItem('token')); + const [isAuthenticated, setAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); useEffect(() => { - const check = () => setAuthenticated(!!localStorage.getItem('token')); - window.addEventListener('storage', check); - return () => window.removeEventListener('storage', check); + // Probe auth state by hitting a protected endpoint. + // If the ws_token cookie is valid, the server returns 200; else 401. + fetch("/api/v1/auth/me", { credentials: "include" }) + .then((r) => setAuthenticated(r.ok)) + .catch(() => setAuthenticated(false)) + .finally(() => setIsLoading(false)); }, []); - const logout = () => { - localStorage.removeItem('token'); + const logout = async () => { + await apiLogout(); setAuthenticated(false); }; return ( - + {children} );