Skip to content

v2.9.1

Choose a tag to compare

@zoltanam zoltanam released this 15 May 22:57

Fix: OAuth refresh-race and relay 401 split-brain

Two compounding bugs were logging users out repeatedly — on CLI restart and mid-session — even when the refresh_token was still valid for 29 more days.

What was broken

  1. Refresh race. Concurrent 401-retries (chat stream + status poll, parallel memory-sync batches, etc.) both presented the same refresh_token to /api/oauth/refresh. First call rotated and revoked it; second call hit a revoked token and got 400 invalid_grant. The blunt non-200 handler treated that as a dead session and kicked the user out.
  2. Local 1h clock killed the UI. AuthToken.is_authenticated gated on time.time() >= expires_at, so once the access_token's 1h TTL elapsed the CLI showed "not connected" without ever firing the call that would have triggered refresh.
  3. Relay split-brain on restart. RelayListener owns its own httpx.AsyncClient (the SSE subscription has to hold it open), so it bypassed APIClient's refresh-on-401. On a fresh CLI start with a locally-stale access_token, the heartbeat hit 401 and fired session_expired immediately — while the login screen, reading the relaxed is_authenticated, correctly reported "logged in". Hence the mismatched indicators.

What changed

  • AuthService.refresh_token: per-process asyncio.Lock + disk re-read inside the lock to dedup concurrent refreshes (adopts a newer disk token without hitting the network). New _classify_refresh_failure splits responses three ways:
    • 200 → success, persist, clear sticky revoked flag.
    • 401/403/400 invalid_grant → set _refresh_grant_revoked, UI asks user to re-authenticate.
    • 429 / 5xx / 400 with other codes / network error → transient, keep credentials, next 401-retry will try again.
  • AuthToken.is_authenticated no longer gates on local expiry — server is the source of truth via the 401-retry path. Stale access_tokens get healed transparently.
  • RelayListener got a refresh_callback parameter and _authed_request helper. Heartbeat / mercure-token / command-result POSTs now refresh-and-retry on 401 before declaring session_expired.
  • validate_token only deletes auth.json when the revoked flag is set — transient failures no longer nuke the file.

Tests

  • 7 new auth tests: lock/dedup path, concurrent refresh serialisation, invalid_grant flagging, 429/5xx/network-error transient handling, success-clears-flag.
  • 2 new relay tests: heartbeat 401 + successful refresh → no session_expired; heartbeat 401 + failed refresh → session_expired still fires.
  • Full suite: 2913 passing.

Coordination

Server-side fix coordinated with servonaut-web-backend — they'll follow up with SELECT ... FOR UPDATE row locking on the refresh-token row + a short idempotency window in TokenService::refreshToken as belt-and-braces. Not load-bearing now that the client serialises, but covers the cross-process and shared-auth.json-over-Dropbox cases.