From d11f3dd00cbfab46c501afb8b051fa0d1da24a8c Mon Sep 17 00:00:00 2001 From: Yunxiao Xu Date: Wed, 18 Feb 2026 13:37:16 -0800 Subject: [PATCH] feat(auth): Implement /refresh endpoint and update login/register --- backend/src/ea_chatbot/api/routers/auth.py | 61 ++++++++++++++++++++-- backend/tests/api/test_auth_refresh.py | 56 ++++++++++++++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 backend/tests/api/test_auth_refresh.py diff --git a/backend/src/ea_chatbot/api/routers/auth.py b/backend/src/ea_chatbot/api/routers/auth.py index 5add78a..4366899 100644 --- a/backend/src/ea_chatbot/api/routers/auth.py +++ b/backend/src/ea_chatbot/api/routers/auth.py @@ -59,7 +59,8 @@ async def register(user_in: UserCreate, response: Response): ) 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 { "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)}) - 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"} @router.post("/logout") async def logout(response: Response): - """Logout by clearing the auth cookie.""" - response.delete_cookie(key="access_token") + """Logout by clearing the auth cookies.""" + clear_auth_cookies(response) 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") async def oidc_login(response: Response): """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 user = history_manager.sync_user_from_oidc(email=email, display_name=name) 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) - set_auth_cookie(redirect_response, access_token) + set_auth_cookie(redirect_response, access_token, refresh_token) redirect_response.delete_cookie("oidc_session") # Cleanup return redirect_response diff --git a/backend/tests/api/test_auth_refresh.py b/backend/tests/api/test_auth_refresh.py new file mode 100644 index 0000000..9a26f66 --- /dev/null +++ b/backend/tests/api/test_auth_refresh.py @@ -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"