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

82 lines
3.2 KiB
Markdown

# 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`:
```python
async def refresh_token(
refresh_token: str, # FastAPI expects query/body param
db: ...
)
```
`frontend/src/api/client.ts:37-41`:
```typescript
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:
```python
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