fix(client): Android app doesn't stay logged in — refresh token is never used #306

Closed
opened 2026-06-26 23:40:06 +00:00 by james · 0 comments
Owner

Problem

On the Android app (and any native/Tauri build), the user is forced to sign in
again roughly every 15 minutes and on every app reopen, even though a valid
30-day refresh token is sitting in SecureStore.

Root cause

The native auth flow mints a short-lived access token (15 min) + long-lived
refresh token (30 days) and stores both in SecureStore
(apps/client/lib/auth/storage.ts). But nothing ever consults the refresh
token:

  • getAuthHeader() (apps/client/lib/auth/getAuthHeader.ts) attaches the
    access token verbatim. When it expires, the API returns 401.
  • The api-client has no 401/expiry handling — getRefreshToken() is defined
    but never called anywhere in the codebase.
  • The protected layout (apps/client/app/(app)/_layout.tsx) redirects to
    /login whenever useMe() errors, so an expired access token → forced
    re-login.

The server side is already complete: POST /api/auth/refresh rotates a refresh
token into a fresh pair (apps/api/app/api/auth/refresh/route.ts, ADR-0027
§6), with mandatory single-use rotation + reuse detection.

Fix

Add proactive, single-flight refresh on the native auth path:

  • Persist the access-token expiry (accessExpiresAt) alongside the tokens.
  • In getAuthHeader(), if the access token is missing/expired/near-expiry,
    call POST /api/auth/refresh with the stored refresh token, persist the new
    pair, and return the fresh access token — before the request goes out.
  • Single-flight so concurrent requests share one refresh (critical: two
    concurrent /refresh calls with the same single-use token would trip the
    server's reuse detection and revoke the whole family).
  • Only when the refresh token itself is invalid/expired (after 30 days, or
    revoked) do we clear tokens and fall back to /login.

Acceptance criteria

  • After the 15-minute access token expires, the next request transparently
    refreshes and succeeds — no re-login.
  • Reopening the app after >15 min (but <30 days) lands on the
    authenticated home screen, not /login.
  • A genuinely expired/revoked refresh token still routes the user to
    /login.
  • Concurrent requests at expiry trigger exactly one /api/auth/refresh.

Out of scope

  • Web (PWA) — uses the carol_session cookie; unaffected.
  • Reactive 401-retry middleware (proactive refresh with a skew window covers
    the failure modes; can revisit if server-side early revocation becomes a
    concern).
## Problem On the Android app (and any native/Tauri build), the user is forced to sign in again roughly every 15 minutes and on every app reopen, even though a valid 30-day refresh token is sitting in SecureStore. ## Root cause The native auth flow mints a short-lived access token (15 min) + long-lived refresh token (30 days) and stores **both** in SecureStore (`apps/client/lib/auth/storage.ts`). But nothing ever consults the refresh token: - `getAuthHeader()` (`apps/client/lib/auth/getAuthHeader.ts`) attaches the access token verbatim. When it expires, the API returns 401. - The api-client has no 401/expiry handling — `getRefreshToken()` is defined but **never called** anywhere in the codebase. - The protected layout (`apps/client/app/(app)/_layout.tsx`) redirects to `/login` whenever `useMe()` errors, so an expired access token → forced re-login. The server side is already complete: `POST /api/auth/refresh` rotates a refresh token into a fresh pair (`apps/api/app/api/auth/refresh/route.ts`, ADR-0027 §6), with mandatory single-use rotation + reuse detection. ## Fix Add **proactive, single-flight** refresh on the native auth path: - Persist the access-token expiry (`accessExpiresAt`) alongside the tokens. - In `getAuthHeader()`, if the access token is missing/expired/near-expiry, call `POST /api/auth/refresh` with the stored refresh token, persist the new pair, and return the fresh access token — before the request goes out. - Single-flight so concurrent requests share one refresh (critical: two concurrent `/refresh` calls with the same single-use token would trip the server's reuse detection and revoke the whole family). - Only when the refresh token itself is invalid/expired (after 30 days, or revoked) do we clear tokens and fall back to `/login`. ## Acceptance criteria - [ ] After the 15-minute access token expires, the next request transparently refreshes and succeeds — no re-login. - [ ] Reopening the app after >15 min (but <30 days) lands on the authenticated home screen, not `/login`. - [ ] A genuinely expired/revoked refresh token still routes the user to `/login`. - [ ] Concurrent requests at expiry trigger exactly one `/api/auth/refresh`. ## Out of scope - Web (PWA) — uses the `carol_session` cookie; unaffected. - Reactive 401-retry middleware (proactive refresh with a skew window covers the failure modes; can revisit if server-side early revocation becomes a concern).
james closed this issue 2026-06-27 00:30:07 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
james/carol#306
No description provided.