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>
This commit is contained in:
81
docs/archive/2026-02-04-token-refresh-fix-design.md
Normal file
81
docs/archive/2026-02-04-token-refresh-fix-design.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user