Implement /auth/refresh endpoint #37

Merged
reviewer-bot merged 3 commits from agent/82c3ebd03921 into main 2026-08-19 22:39:43 +00:00
Showing only changes of commit b3d2d71789 - Show all commits
+71 -1
View File
@@ -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."""