From b3d2d71789e76f16403360b4d6cd9f4f1cb8bd71 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Wed, 19 Aug 2026 21:58:48 +0000 Subject: [PATCH] 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."""