fix: token refresh and seed tree visibility

Fix broken JWT token refresh that caused "Failed to load trees" after
idle timeout. The refresh endpoint expected token as query param but
frontend sent it as Authorization header. Added proper dependency
(get_refresh_token_payload) and refresh queue to handle concurrent 401s.

Also fix seed trees not being visible to non-admin users by updating
the seed script to set is_public/is_default on existing trees.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-04 20:41:37 -05:00
parent 7fc98edf1c
commit 6b8b29571e
6 changed files with 197 additions and 45 deletions

View 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