feat(api): Implement authentication router and secure dependencies

This commit is contained in:
Yunxiao Xu
2026-02-10 12:37:35 -08:00
parent 979e1ad2d6
commit ff27dee366
3 changed files with 73 additions and 30 deletions

View File

@@ -0,0 +1,46 @@
import os
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from ea_chatbot.config import Settings
from ea_chatbot.history.manager import HistoryManager
from ea_chatbot.auth import OIDCClient
from ea_chatbot.api.utils import decode_access_token
from ea_chatbot.history.models import User
settings = Settings()
# Shared instances
history_manager = HistoryManager(settings.history_db_url)
oidc_client = None
if settings.oidc_client_id and settings.oidc_client_secret and settings.oidc_server_metadata_url:
oidc_client = OIDCClient(
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
server_metadata_url=settings.oidc_server_metadata_url,
redirect_uri=os.getenv("OIDC_REDIRECT_URI", "http://localhost:3000/auth/callback")
)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
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"},
)
payload = decode_access_token(token)
if payload is None:
raise credentials_exception
username: str | None = payload.get("sub")
if username is None:
raise credentials_exception
user = history_manager.get_user(username)
if user is None:
raise credentials_exception
return user

View File

@@ -1,30 +1,13 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from typing import Optional from typing import Optional
from ea_chatbot.config import Settings
from ea_chatbot.history.manager import HistoryManager
from ea_chatbot.auth import OIDCClient
from ea_chatbot.api.utils import create_access_token from ea_chatbot.api.utils import create_access_token
from ea_chatbot.api.dependencies import history_manager, oidc_client, get_current_user
settings = Settings() from ea_chatbot.history.models import User as UserDB
history_manager = HistoryManager(settings.history_db_url)
# Initialize OIDC Client if configured
oidc_client = None
if settings.oidc_client_id and settings.oidc_client_secret and settings.oidc_server_metadata_url:
oidc_client = OIDCClient(
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
server_metadata_url=settings.oidc_server_metadata_url,
# This will be updated to the frontend URL later
redirect_uri="http://localhost:3000/auth/callback"
)
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
token_type: str token_type: str
@@ -79,7 +62,7 @@ async def oidc_login():
"""Get the OIDC authorization URL.""" """Get the OIDC authorization URL."""
if not oidc_client: if not oidc_client:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, status_code=status.HTTP_510_NOT_EXTENDED,
detail="OIDC is not configured" detail="OIDC is not configured"
) )
@@ -87,12 +70,10 @@ async def oidc_login():
return {"url": url} return {"url": url}
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
async def get_me(token: str = Depends(oauth2_scheme)): async def get_me(current_user: UserDB = Depends(get_current_user)):
"""Get the current authenticated user's profile.""" """Get the current authenticated user's profile."""
# This currently only validates the token exists via oauth2_scheme
# The next task will implement the full dependency to decode JWT and fetch user
return { return {
"id": "unknown", "id": str(current_user.id),
"email": "unknown", "email": current_user.username,
"display_name": "unknown" "display_name": current_user.display_name
} }

View File

@@ -20,7 +20,7 @@ def mock_user():
def test_register_user_success(): def test_register_user_success():
"""Test successful user registration.""" """Test successful user registration."""
# We'll need to mock history_manager.get_user and create_user # We mock it where it is used in the router
with patch("ea_chatbot.api.routers.auth.history_manager") as mock_hm: with patch("ea_chatbot.api.routers.auth.history_manager") as mock_hm:
mock_hm.get_user.return_value = None mock_hm.get_user.return_value = None
mock_hm.create_user.return_value = User(id="1", username="new@example.com", display_name="New") mock_hm.create_user.return_value = User(id="1", username="new@example.com", display_name="New")
@@ -30,7 +30,6 @@ def test_register_user_success():
json={"email": "new@example.com", "password": "password123", "display_name": "New"} json={"email": "new@example.com", "password": "password123", "display_name": "New"}
) )
# This will fail now because the router doesn't exist
assert response.status_code == 201 assert response.status_code == 201
assert response.json()["email"] == "new@example.com" assert response.json()["email"] == "new@example.com"
@@ -74,3 +73,20 @@ def test_oidc_login_redirect():
response = client.get("/auth/oidc/login") response = client.get("/auth/oidc/login")
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["url"] == "https://oidc-provider.com/auth" assert response.json()["url"] == "https://oidc-provider.com/auth"
def test_get_me_success():
"""Test getting current user with a valid token."""
from ea_chatbot.api.utils import create_access_token
token = create_access_token(data={"sub": "test@example.com", "user_id": "123"})
with patch("ea_chatbot.api.dependencies.history_manager") as mock_hm:
mock_hm.get_user.return_value = User(id="123", username="test@example.com", display_name="Test")
response = client.get(
"/auth/me",
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
assert response.json()["email"] == "test@example.com"
assert response.json()["id"] == "123"