feat(auth): Implement /refresh endpoint and update login/register

This commit is contained in:
Yunxiao Xu
2026-02-18 13:37:16 -08:00
parent 78265c399a
commit d11f3dd00c
2 changed files with 112 additions and 5 deletions

View File

@@ -59,7 +59,8 @@ async def register(user_in: UserCreate, response: Response):
) )
access_token = create_access_token(data={"sub": str(user.id)}) access_token = create_access_token(data={"sub": str(user.id)})
set_auth_cookie(response, access_token) refresh_token = create_refresh_token(data={"sub": str(user.id)})
set_auth_cookie(response, access_token, refresh_token)
return { return {
"id": str(user.id), "id": str(user.id),
@@ -80,15 +81,64 @@ async def login(response: Response, form_data: OAuth2PasswordRequestForm = Depen
) )
access_token = create_access_token(data={"sub": str(user.id)}) access_token = create_access_token(data={"sub": str(user.id)})
set_auth_cookie(response, access_token) refresh_token = create_refresh_token(data={"sub": str(user.id)})
set_auth_cookie(response, access_token, refresh_token)
return {"access_token": access_token, "token_type": "bearer"} return {"access_token": access_token, "token_type": "bearer"}
@router.post("/logout") @router.post("/logout")
async def logout(response: Response): async def logout(response: Response):
"""Logout by clearing the auth cookie.""" """Logout by clearing the auth cookies."""
response.delete_cookie(key="access_token") clear_auth_cookies(response)
return {"detail": "Successfully logged out"} return {"detail": "Successfully logged out"}
@router.post("/refresh", response_model=Token)
async def refresh(request: Request, response: Response):
"""Refresh the access token using a valid refresh token."""
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token missing"
)
from ea_chatbot.api.utils import decode_access_token # Using decode_access_token for both
payload = decode_access_token(refresh_token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload"
)
# Check if user still exists
user = history_manager.get_user_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
# Issue new tokens (Rotation)
new_access_token = create_access_token(data={"sub": str(user.id)})
new_refresh_token = create_refresh_token(data={"sub": str(user.id)})
set_auth_cookie(response, new_access_token, new_refresh_token)
return {"access_token": new_access_token, "token_type": "bearer"}
@router.get("/oidc/login") @router.get("/oidc/login")
async def oidc_login(response: Response): async def oidc_login(response: Response):
"""Get the OIDC authorization URL and set temporary session cookie.""" """Get the OIDC authorization URL and set temporary session cookie."""
@@ -158,9 +208,10 @@ async def oidc_callback(request: Request, response: Response, code: str, state:
# 4. Sync user and establish session # 4. Sync user and establish session
user = history_manager.sync_user_from_oidc(email=email, display_name=name) user = history_manager.sync_user_from_oidc(email=email, display_name=name)
access_token = create_access_token(data={"sub": str(user.id)}) access_token = create_access_token(data={"sub": str(user.id)})
refresh_token = create_refresh_token(data={"sub": str(user.id)})
redirect_response = RedirectResponse(url=settings.frontend_url, status_code=status.HTTP_302_FOUND) redirect_response = RedirectResponse(url=settings.frontend_url, status_code=status.HTTP_302_FOUND)
set_auth_cookie(redirect_response, access_token) set_auth_cookie(redirect_response, access_token, refresh_token)
redirect_response.delete_cookie("oidc_session") # Cleanup redirect_response.delete_cookie("oidc_session") # Cleanup
return redirect_response return redirect_response

View File

@@ -0,0 +1,56 @@
import pytest
from fastapi.testclient import TestClient
from ea_chatbot.api.main import app
from ea_chatbot.api.utils import create_refresh_token, create_access_token
from unittest.mock import patch, MagicMock
from ea_chatbot.history.models import User
@pytest.fixture
def client():
return TestClient(app)
def test_refresh_token_success(client):
# 1. Setup: Create a valid refresh token for a user
user_id = "test-user-id"
refresh_token = create_refresh_token({"sub": user_id})
# 2. Set the cookie manually in the client
client.cookies.set("refresh_token", refresh_token)
import time
time.sleep(1.1) # Wait to ensure iat is different
# 3. Call the refresh endpoint with mock
with patch("ea_chatbot.api.routers.auth.history_manager") as mock_hm:
mock_hm.get_user_by_id.return_value = User(id=user_id, username="test@test.com")
response = client.post("/api/v1/auth/refresh")
# 4. Assert success
assert response.status_code == 200
assert "access_token" in response.cookies
assert "refresh_token" in response.cookies
# Verify tokens are rotated (different from original)
assert response.cookies["refresh_token"] != refresh_token
def test_refresh_token_missing_cookie(client):
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 401
assert response.json()["detail"] == "Refresh token missing"
def test_refresh_token_invalid(client):
client.cookies.set("refresh_token", "invalid-token")
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 401
assert response.json()["detail"] == "Invalid refresh token"
def test_refresh_token_wrong_type(client):
# Using an access token as a refresh token should fail
access_token = create_access_token({"sub": "user123"})
client.cookies.set("refresh_token", access_token)
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 401
assert response.json()["detail"] == "Invalid token type"