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>
82 lines
3.2 KiB
Markdown
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
|