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>
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_tokenas a barestrquery/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=1temporarily 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