feat(backend): Implement /api/v1 prefix and HttpOnly cookie-based auth

This commit is contained in:
Yunxiao Xu
2026-02-11 21:57:29 -08:00
parent 7a69133e26
commit 49a9da7c0c
5 changed files with 318 additions and 16 deletions

View File

@@ -1,5 +1,5 @@
import os
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from ea_chatbot.config import Settings
from ea_chatbot.history.manager import HistoryManager
@@ -21,16 +21,23 @@ if settings.oidc_client_id and settings.oidc_client_secret and settings.oidc_ser
redirect_uri=os.getenv("OIDC_REDIRECT_URI", "http://localhost:3000/auth/callback")
)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login", auto_error=False)
async def get_current_user(request: Request, token: str = Depends(oauth2_scheme)) -> User:
"""Dependency to get the current authenticated user from the JWT token (cookie or header)."""
# Try getting token from cookie first
if not token:
token = request.cookies.get("access_token")
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
"""Dependency to get the current authenticated user from the JWT token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if not token:
raise credentials_exception
payload = decode_access_token(token)
if payload is None:
raise credentials_exception

View File

@@ -1,4 +1,4 @@
from fastapi import FastAPI
from fastapi import FastAPI, APIRouter
from fastapi.middleware.cors import CORSMiddleware
from ea_chatbot.api.routers import auth, history, artifacts, agent
from dotenv import load_dotenv
@@ -20,10 +20,16 @@ app.add_middleware(
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(history.router)
app.include_router(artifacts.router)
app.include_router(agent.router)
# API v1 Router
api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(auth.router)
api_v1_router.include_router(history.router)
api_v1_router.include_router(artifacts.router)
api_v1_router.include_router(agent.router)
# Include v1 router in app
app.include_router(api_v1_router)
@app.get("/health")
async def health_check():

View File

@@ -1,14 +1,29 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.responses import RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from ea_chatbot.api.utils import create_access_token
from ea_chatbot.api.dependencies import history_manager, oidc_client, get_current_user
from ea_chatbot.history.models import User as UserDB
from ea_chatbot.api.schemas import Token, UserCreate, UserResponse
import os
router = APIRouter(prefix="/auth", tags=["auth"])
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
def set_auth_cookie(response: Response, token: str):
response.set_cookie(
key="access_token",
value=token,
httponly=True,
max_age=1800,
expires=1800,
samesite="lax",
secure=False, # Set to True in production with HTTPS
)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(user_in: UserCreate):
async def register(user_in: UserCreate, response: Response):
"""Register a new user."""
user = history_manager.get_user(user_in.email)
if user:
@@ -22,6 +37,10 @@ async def register(user_in: UserCreate):
password=user_in.password,
display_name=user_in.display_name
)
access_token = create_access_token(data={"sub": str(user.id)})
set_auth_cookie(response, access_token)
return {
"id": str(user.id),
"email": user.username,
@@ -29,7 +48,7 @@ async def register(user_in: UserCreate):
}
@router.post("/login", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
async def login(response: Response, form_data: OAuth2PasswordRequestForm = Depends()):
"""Login with email and password to get a JWT."""
user = history_manager.authenticate_user(form_data.username, form_data.password)
if not user:
@@ -40,8 +59,15 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()):
)
access_token = create_access_token(data={"sub": str(user.id)})
set_auth_cookie(response, access_token)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/logout")
async def logout(response: Response):
"""Logout by clearing the auth cookie."""
response.delete_cookie(key="access_token")
return {"detail": "Successfully logged out"}
@router.get("/oidc/login")
async def oidc_login():
"""Get the OIDC authorization URL."""
@@ -54,9 +80,9 @@ async def oidc_login():
url = oidc_client.get_login_url()
return {"url": url}
@router.get("/oidc/callback", response_model=Token)
@router.get("/oidc/callback")
async def oidc_callback(code: str):
"""Handle the OIDC callback and issue a JWT."""
"""Handle the OIDC callback, issue a JWT, and redirect to frontend."""
if not oidc_client:
raise HTTPException(status_code=status.HTTP_510_NOT_EXTENDED, detail="OIDC not configured")
@@ -72,9 +98,13 @@ async def oidc_callback(code: str):
user = history_manager.sync_user_from_oidc(email=email, display_name=name)
access_token = create_access_token(data={"sub": str(user.id)})
return {"access_token": access_token, "token_type": "bearer"}
response = RedirectResponse(url=f"{FRONTEND_URL}/auth/callback")
set_auth_cookie(response, access_token)
return response
except Exception as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"OIDC authentication failed: {str(e)}")
# Redirect to frontend with error
return RedirectResponse(url=f"{FRONTEND_URL}?error=oidc_failed")
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: UserDB = Depends(get_current_user)):