Files
resolutionflow/docs/archive/2026-02-04-token-refresh-fix-design.md
Michael Chihlas 89d343d49a chore: archive 11 completed plan documents
Move completed design/implementation docs from docs/plans/ to docs/archive/
to keep the plans folder focused on active and future work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:51:21 -05:00

3.2 KiB

Token Refresh Fix Design

Date: 2026-02-04 Status: Approved Issue: After idle period, app shows "Failed to load trees" with 401 cascade


Problem

When the access token expires (15 minutes), the Axios response interceptor attempts to refresh it by calling POST /api/v1/auth/refresh. This call fails because of a request/response mismatch:

  • Frontend sends the refresh token in the Authorization: Bearer <token> header
  • Backend expects refresh_token as a bare str query/body parameter

FastAPI cannot extract a bare string parameter from the Authorization header, so the refresh call returns 422 (missing required parameter). The interceptor catches this as a generic failure, but the error propagates to the calling component rather than redirecting to login — leaving the user stuck on a broken page.

Additionally, when multiple API calls (trees, folders, categories) all return 401 simultaneously, each independently triggers its own refresh attempt, creating a race condition.

Root Cause

backend/app/api/endpoints/auth.py:156-158:

async def refresh_token(
    refresh_token: str,  # FastAPI expects query/body param
    db: ...
)

frontend/src/api/client.ts:37-41:

await axios.post(url, null, {
  headers: { Authorization: `Bearer ${refreshToken}` }  // Sent as header
})

Solution

1. Backend: Add get_refresh_token_payload dependency

Create a proper FastAPI dependency in deps.py that extracts and validates a refresh token from the Authorization header, mirroring the existing get_current_user pattern:

async def get_refresh_token_payload(
    token: Annotated[str, Depends(oauth2_scheme)]
) -> dict:
    payload = decode_token(token)
    if payload is None or payload.get("type") != "refresh":
        raise HTTPException(status_code=401, detail="Invalid refresh token")
    return payload

2. Backend: Refactor refresh endpoint

Change refresh_token() in auth.py to use the new dependency instead of a bare string parameter.

3. Frontend: Add refresh queue (single-flight pattern)

When multiple requests hit 401 simultaneously, only the first triggers the actual refresh call. Others queue up and retry with the new token once the refresh completes.

4. Frontend: Sync auth store after refresh

Add a setTokens action to authStore.ts. Call it from the interceptor after a successful refresh so the Zustand store stays consistent with localStorage.

Files Changed

File Change
backend/app/api/deps.py Add get_refresh_token_payload dependency
backend/app/api/endpoints/auth.py Use new dependency in refresh endpoint
frontend/src/api/client.ts Refresh queue + auth store sync
frontend/src/store/authStore.ts Add setTokens action

Testing

  • Set ACCESS_TOKEN_EXPIRE_MINUTES=1 temporarily to reproduce quickly
  • Verify silent refresh: login, wait >1 min, interact — no errors visible
  • Verify concurrent requests: page load after expiry triggers one refresh, all requests succeed
  • Verify refresh token expiry: after 7 days, clean redirect to login
  • Run existing backend tests (pytest) to confirm no regressions
  • Run frontend build (npm run build) to confirm no compile errors