# 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
