Switch refresh token storage from cookies to localStorage

This commit is contained in:
CREDO23 2026-02-05 17:55:21 +02:00
parent f3a9922eb9
commit 233852b681
7 changed files with 160 additions and 88 deletions

View file

@ -2,17 +2,18 @@
import logging
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from app.db import User, async_session_maker
from app.schemas.auth import LogoutAllResponse, LogoutResponse, RefreshTokenResponse
from app.users import current_active_user, get_jwt_strategy
from app.utils.auth_cookies import (
REFRESH_TOKEN_COOKIE_NAME,
delete_refresh_token_cookie,
set_refresh_token_cookie,
from app.schemas.auth import (
LogoutAllResponse,
LogoutRequest,
LogoutResponse,
RefreshTokenRequest,
RefreshTokenResponse,
)
from app.users import current_active_user, get_jwt_strategy
from app.utils.refresh_tokens import (
revoke_all_user_tokens,
revoke_refresh_token,
@ -26,21 +27,12 @@ router = APIRouter(prefix="/auth/jwt", tags=["auth"])
@router.post("/refresh", response_model=RefreshTokenResponse)
async def refresh_access_token(
response: Response,
refresh_token: str | None = Cookie(default=None, alias=REFRESH_TOKEN_COOKIE_NAME),
):
async def refresh_access_token(request: RefreshTokenRequest):
"""
Exchange a valid refresh token for a new access token and refresh token.
Reads refresh token from HTTP-only cookie. Implements token rotation for security.
Implements token rotation for security.
"""
if not refresh_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token not found",
)
token_record = await validate_refresh_token(refresh_token)
token_record = await validate_refresh_token(request.refresh_token)
if not token_record:
raise HTTPException(
@ -68,9 +60,6 @@ async def refresh_access_token(
# Rotate refresh token
new_refresh_token = await rotate_refresh_token(token_record)
# Set the new refresh token in cookie
set_refresh_token_cookie(response, new_refresh_token)
logger.info(f"Refreshed token for user {user.id}")
return RefreshTokenResponse(
@ -80,36 +69,21 @@ async def refresh_access_token(
@router.post("/logout", response_model=LogoutResponse)
async def logout(
response: Response,
refresh_token: str | None = Cookie(default=None, alias=REFRESH_TOKEN_COOKIE_NAME),
):
async def logout(request: LogoutRequest):
"""
Logout current device by revoking the refresh token from cookie.
Logout current device by revoking the provided refresh token.
"""
if refresh_token:
await revoke_refresh_token(refresh_token)
# Always delete the cookie
delete_refresh_token_cookie(response)
await revoke_refresh_token(request.refresh_token)
logger.info("User logged out from current device")
return LogoutResponse()
@router.post("/logout-all", response_model=LogoutAllResponse)
async def logout_all_devices(
response: Response,
user: User = Depends(current_active_user),
):
async def logout_all_devices(user: User = Depends(current_active_user)):
"""
Logout from all devices by revoking all refresh tokens for the user.
Requires valid access token.
"""
await revoke_all_user_tokens(user.id)
# Delete the cookie on current device
delete_refresh_token_cookie(response)
logger.info(f"User {user.id} logged out from all devices")
return LogoutAllResponse()

View file

@ -1,4 +1,10 @@
from .auth import LogoutAllResponse, LogoutResponse, RefreshTokenResponse
from .auth import (
LogoutAllResponse,
LogoutRequest,
LogoutResponse,
RefreshTokenRequest,
RefreshTokenResponse,
)
from .base import IDModel, TimestampModel
from .chunks import ChunkBase, ChunkCreate, ChunkRead, ChunkUpdate
from .documents import (
@ -120,6 +126,7 @@ __all__ = [
"LogUpdate",
# Auth schemas
"LogoutAllResponse",
"LogoutRequest",
"LogoutResponse",
# Search source connector schemas
"MCPConnectorCreate",
@ -150,6 +157,7 @@ __all__ = [
"PodcastCreate",
"PodcastRead",
"PodcastUpdate",
"RefreshTokenRequest",
"RefreshTokenResponse",
"RoleCreate",
"RoleRead",

View file

@ -3,6 +3,12 @@
from pydantic import BaseModel
class RefreshTokenRequest(BaseModel):
"""Request body for token refresh endpoint."""
refresh_token: str
class RefreshTokenResponse(BaseModel):
"""Response from token refresh endpoint."""
@ -11,6 +17,12 @@ class RefreshTokenResponse(BaseModel):
token_type: str = "bearer"
class LogoutRequest(BaseModel):
"""Request body for logout endpoint (current device)."""
refresh_token: str
class LogoutResponse(BaseModel):
"""Response from logout endpoint (current device)."""

View file

@ -23,7 +23,6 @@ from app.db import (
get_default_roles_config,
get_user_db,
)
from app.utils.auth_cookies import set_refresh_token_cookie
from app.utils.refresh_tokens import create_refresh_token
logger = logging.getLogger(__name__)
@ -238,16 +237,11 @@ class CustomBearerTransport(BearerTransport):
redirect_url = (
f"{config.NEXT_FRONTEND_URL}/auth/callback"
f"?token={bearer_response.access_token}"
f"&refresh_token={bearer_response.refresh_token}"
)
response = RedirectResponse(redirect_url, status_code=302)
return RedirectResponse(redirect_url, status_code=302)
else:
response = JSONResponse(bearer_response.model_dump())
# Set refresh token as HTTP-only cookie
if refresh_token:
set_refresh_token_cookie(response, refresh_token)
return response
return JSONResponse(bearer_response.model_dump())
bearer_transport = CustomBearerTransport(tokenUrl="auth/jwt/login")

View file

@ -1,29 +0,0 @@
"""Utilities for managing authentication cookies."""
from fastapi import Response
from app.config import config
REFRESH_TOKEN_COOKIE_NAME = "refresh_token"
def set_refresh_token_cookie(response: Response, token: str) -> None:
"""Set the refresh token as an HTTP-only cookie."""
response.set_cookie(
key=REFRESH_TOKEN_COOKIE_NAME,
value=token,
max_age=config.REFRESH_TOKEN_LIFETIME_SECONDS,
httponly=True,
secure=True, # Only send over HTTPS
samesite="lax",
)
def delete_refresh_token_cookie(response: Response) -> None:
"""Delete the refresh token cookie."""
response.delete_cookie(
key=REFRESH_TOKEN_COOKIE_NAME,
httponly=True,
secure=True,
samesite="lax",
)