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) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-04-29 22:02:02 -06:00
parent 7f602a67af
commit 140a75f706
8 changed files with 257 additions and 26 deletions

View File

@@ -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"]

View File

@@ -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}

View File

@@ -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)

View File

@@ -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"

View File

@@ -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}

View File

@@ -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

View File

@@ -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:

View File

@@ -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<void>;
setAuthenticated: (v: boolean) => void;
}
const AuthContext = createContext<AuthCtx>({
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 (
<AuthContext.Provider value={{ isAuthenticated, logout, setAuthenticated }}>
<AuthContext.Provider value={{ isAuthenticated, isLoading, logout, setAuthenticated }}>
{children}
</AuthContext.Provider>
);