From b3d2d71789e76f16403360b4d6cd9f4f1cb8bd71 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Wed, 19 Aug 2026 21:58:48 +0000 Subject: [PATCH 1/3] WIP checkpoint: Implement /auth/refresh endpoint --- backend/app/routers/auth.py | 72 ++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 260042f..79a5f91 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -5,7 +5,7 @@ from datetime import date, datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm -from jose import jwt +from jose import JWTError, jwt from passlib.context import CryptContext from pydantic import BaseModel, EmailStr, field_validator from sqlalchemy import select @@ -31,6 +31,10 @@ class TokenResponse(BaseModel): access_token_expires_minutes: int +class RefreshRequest(BaseModel): + refresh_token: str + + class RegisterRequest(BaseModel): username: str email: EmailStr @@ -232,6 +236,72 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = ) +@router.post("/refresh", response_model=TokenResponse) +async def refresh(req: RefreshRequest, db: AsyncSession = Depends(get_db)): + """Exchange a valid refresh token for a new access token. + + Validates the refresh token against the database: + - Must exist in the refresh_tokens table + - Must not be expired (expires_at > now) + - Must be active (is_active == true) + + On success, returns a new access token. The refresh token itself is + NOT rotated (the client keeps using the same refresh token). + """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token", + ) + + # Step 1: Decode and validate the JWT signature/claims + try: + payload = jwt.decode( + req.refresh_token, + settings.public_pem, + algorithms=[settings.ALGORITHM], + ) + except JWTError: + raise credentials_exception + + user_id_str: str | None = payload.get("sub") + if user_id_str is None: + raise credentials_exception + + try: + user_id = int(user_id_str) + except (ValueError, TypeError): + raise credentials_exception + + # Step 2: Look up the refresh token in the database + result = await db.execute( + select(RefreshToken).where( + RefreshToken.token == req.refresh_token, + RefreshToken.user_id == user_id, + ) + ) + stored_token = result.scalar_one_or_none() + + if stored_token is None: + raise credentials_exception + + # Step 3: Check if the token is expired + if datetime.now(timezone.utc) >= stored_token.expires_at: + raise credentials_exception + + # Step 4: Check if the token is active + if not stored_token.is_active: + raise credentials_exception + + # Step 5: Issue a new access token + access_token = _create_access_token({"sub": str(user_id)}) + + return TokenResponse( + access_token=access_token, + refresh_token=req.refresh_token, # Return the same refresh token + access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES, + ) + + @router.get("/me", response_model=UserResponse) async def get_current_user(current_user: CurrentUser): """Get current user profile from the JWT token.""" From 3e81d60268edfb259256f26f6e789e9fbcf53388 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Wed, 19 Aug 2026 22:03:00 +0000 Subject: [PATCH 2/3] WIP checkpoint: Implement /auth/refresh endpoint --- backend/tests/test_refresh_endpoint.py | 201 +++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 backend/tests/test_refresh_endpoint.py diff --git a/backend/tests/test_refresh_endpoint.py b/backend/tests/test_refresh_endpoint.py new file mode 100644 index 0000000..995bf0f --- /dev/null +++ b/backend/tests/test_refresh_endpoint.py @@ -0,0 +1,201 @@ +"""Tests for the POST /api/auth/refresh endpoint. + +These tests exercise the full stack: register → get refresh token → +refresh → new access token. Requires migrations already applied +(alembic upgrade head) before running. +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.config import settings +from app.main import app +from app.database import get_db + + +# --------------------------------------------------------------------------- +# Test fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _override_db(): + """Override get_db so the app uses a fresh session per test.""" + test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) + test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False) + + async def _get_db(): + async_session = test_session_factory() + try: + yield async_session + await async_session.commit() + except Exception: + await async_session.rollback() + raise + finally: + await async_session.close() + + app.dependency_overrides[get_db] = _get_db + yield + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Refresh endpoint tests +# --------------------------------------------------------------------------- + +class TestRefresh: + """Tests for POST /api/auth/refresh.""" + + def _register_user(self, username: str, password: str, email: str = None): + """Helper to register a user and return the refresh token.""" + client = TestClient(app) + if email is None: + email = f"{username}@example.com" + resp = client.post( + "/api/auth/register", + json={ + "username": username, + "email": email, + "password": password, + "date_of_birth": "2000-01-01", + }, + ) + assert resp.status_code == 201 + return resp.json()["refresh_token"] + + def test_refresh_success(self): + """A valid refresh token returns a new access token.""" + refresh_token = self._register_user("refreshuser", "secret123") + client = TestClient(app) + resp = client.post( + "/api/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + # The refresh token in the response is the same one we sent + assert data["refresh_token"] == refresh_token + + def test_refresh_returns_different_access_token(self): + """Refreshing produces a new access token (different from any previous).""" + refresh_token = self._register_user("refreshtoken", "secret123") + client = TestClient(app) + + # First refresh + resp1 = client.post( + "/api/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp1.status_code == 200 + access_token_1 = resp1.json()["access_token"] + + # Second refresh + resp2 = client.post( + "/api/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp2.status_code == 200 + access_token_2 = resp2.json()["access_token"] + + # Each refresh should produce a new access token + assert access_token_1 != access_token_2 + + def test_refresh_invalid_token(self): + """An invalid (garbage) refresh token returns 401.""" + client = TestClient(app) + resp = client.post( + "/api/auth/refresh", + json={"refresh_token": "not.a.jwt.token"}, + ) + assert resp.status_code == 401 + assert "Invalid or expired refresh token" in resp.json()["detail"] + + def test_refresh_empty_body(self): + """Missing refresh_token field returns 422 validation error.""" + client = TestClient(app) + resp = client.post( + "/api/auth/refresh", + json={}, + ) + assert resp.status_code == 422 + + def test_refresh_missing_field(self): + """No body at all returns 422.""" + client = TestClient(app) + resp = client.post( + "/api/auth/refresh", + content="{}", + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 422 + + def test_refresh_with_login_token(self): + """A refresh token obtained from login also works for /refresh.""" + client = TestClient(app) + # Register first + client.post( + "/api/auth/register", + json={ + "username": "loginrefuser", + "email": "loginref@example.com", + "password": "secret123", + "date_of_birth": "2000-01-01", + }, + ) + # Login to get a refresh token + login_resp = client.post( + "/api/auth/login", + data={"username": "loginrefuser", "password": "secret123"}, + ) + assert login_resp.status_code == 200 + refresh_token = login_resp.json()["refresh_token"] + + # Use it to refresh + resp = client.post( + "/api/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp.status_code == 200 + assert "access_token" in resp.json() + + def test_refresh_access_token_is_valid_jwt(self): + """The access token returned by /refresh is a valid JWT.""" + from jose import jwt + + refresh_token = self._register_user("jwttoken", "secret123") + client = TestClient(app) + resp = client.post( + "/api/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp.status_code == 200 + access_token = resp.json()["access_token"] + + # Verify it's a 3-part JWT + parts = access_token.split(".") + assert len(parts) == 3 + + # Verify it decodes with the public key + payload = jwt.decode( + access_token, + settings.public_pem, + algorithms=[settings.ALGORITHM], + ) + assert "sub" in payload + assert "exp" in payload + + def test_refresh_expires_minutes_in_response(self): + """The response includes access_token_expires_minutes.""" + refresh_token = self._register_user("expiretest", "secret123") + client = TestClient(app) + resp = client.post( + "/api/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp.status_code == 200 + assert "access_token_expires_minutes" in resp.json() + assert resp.json()["access_token_expires_minutes"] == settings.ACCESS_TOKEN_EXPIRE_MINUTES From 3cb4d01073f748ddf35bbcbe3f6162119e3edd0f Mon Sep 17 00:00:00 2001 From: coder-bot Date: Wed, 19 Aug 2026 22:15:55 +0000 Subject: [PATCH 3/3] Fix access token collision and cross-file username collision - auth.py: _create_access_token had no jti claim, so two refreshes within the same wall-clock second produced byte-identical access tokens (same failure mode already fixed for refresh tokens earlier) - test_refresh_endpoint.py: usernames (jwttoken, refreshuser, etc.) collided with test_auth_endpoints.py's own test usernames once run in the same pytest session against a shared DB with no per-test isolation -- exactly how run_checkpoint_tests.py invokes pytest. Namespaced this file's usernames with a refep_ prefix. Full backend suite (63 tests) passes clean end-to-end against a real Postgres with real migrations applied. --- backend/app/routers/auth.py | 9 ++++++++- backend/tests/test_refresh_endpoint.py | 12 ++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 79a5f91..61e99ae 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -66,8 +66,15 @@ class UserResponse(BaseModel): # --- Helpers --- def _create_access_token(data: dict) -> str: - """Create a signed access token using RS256.""" + """Create a signed access token using RS256. + + Includes a unique ``jti`` claim so that concurrent/same-second calls + (e.g. back-to-back /refresh requests) still produce distinct tokens — + without it, two tokens minted for the same user within the same + second are byte-identical since ``sub``/``exp`` are the only claims. + """ to_encode = data.copy() + to_encode["jti"] = str(uuid.uuid4()) expire = datetime.now(timezone.utc) + timedelta( minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES ) diff --git a/backend/tests/test_refresh_endpoint.py b/backend/tests/test_refresh_endpoint.py index 995bf0f..92e4322 100644 --- a/backend/tests/test_refresh_endpoint.py +++ b/backend/tests/test_refresh_endpoint.py @@ -67,7 +67,7 @@ class TestRefresh: def test_refresh_success(self): """A valid refresh token returns a new access token.""" - refresh_token = self._register_user("refreshuser", "secret123") + refresh_token = self._register_user("refep_refreshuser", "secret123") client = TestClient(app) resp = client.post( "/api/auth/refresh", @@ -82,7 +82,7 @@ class TestRefresh: def test_refresh_returns_different_access_token(self): """Refreshing produces a new access token (different from any previous).""" - refresh_token = self._register_user("refreshtoken", "secret123") + refresh_token = self._register_user("refep_refreshtoken", "secret123") client = TestClient(app) # First refresh @@ -140,7 +140,7 @@ class TestRefresh: client.post( "/api/auth/register", json={ - "username": "loginrefuser", + "username": "refep_loginrefuser", "email": "loginref@example.com", "password": "secret123", "date_of_birth": "2000-01-01", @@ -149,7 +149,7 @@ class TestRefresh: # Login to get a refresh token login_resp = client.post( "/api/auth/login", - data={"username": "loginrefuser", "password": "secret123"}, + data={"username": "refep_loginrefuser", "password": "secret123"}, ) assert login_resp.status_code == 200 refresh_token = login_resp.json()["refresh_token"] @@ -166,7 +166,7 @@ class TestRefresh: """The access token returned by /refresh is a valid JWT.""" from jose import jwt - refresh_token = self._register_user("jwttoken", "secret123") + refresh_token = self._register_user("refep_jwttoken", "secret123") client = TestClient(app) resp = client.post( "/api/auth/refresh", @@ -190,7 +190,7 @@ class TestRefresh: def test_refresh_expires_minutes_in_response(self): """The response includes access_token_expires_minutes.""" - refresh_token = self._register_user("expiretest", "secret123") + refresh_token = self._register_user("refep_expiretest", "secret123") client = TestClient(app) resp = client.post( "/api/auth/refresh",