fix(client): keep the Android app logged in via proactive token refresh (#306) #307
No reviewers
Labels
No labels
area:auth
area:ci
area:db
area:infra
area:native
area:pwa
area:service
epic
feature
foundation
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol!307
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "native-stay-logged-in"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #306.
Problem
The Android app (and any native/Tauri build) forced the user to sign in again
roughly every 15 minutes and on every app reopen, even though a valid 30-day
refresh token was sitting in SecureStore.
Root cause
The native flow minted a 15-min access token + 30-day refresh token and stored
both, but nothing ever used the refresh token.
getAuthHeader()attached theaccess token verbatim; once it expired the API 401'd and the protected layout
redirected to
/login.getRefreshToken()was defined but never calledanywhere. The server side (
POST /api/auth/refresh, ADR-0027 §6) was alreadycomplete.
Fix — proactive, single-flight refresh
setTokensnow storesaccessExpiresAtalongsidethe tokens;
storage.tscaches it in memory next to the access token.lib/auth/refresh.ts(new) —ensureFreshAccessToken(). Returns ausable access token, refreshing via
POST /api/auth/refreshfirst when thecurrent one is missing, expired, or within a 30s skew of expiry.
getAuthHeader()now calls it, so refresh happens before the request goesout (no fragile 401-retry / request-body replay on React Native).
requests at expiry trigger exactly one
/api/auth/refresh. This is required:refresh tokens are single-use and the server revokes the whole token family
if it sees one replayed (reuse detection).
fetch(not the openapi-fetchclient), so it can't loop back through
getAuthHeader()and deadlock, and itneeds no bearer header.
reuse-detected → 401) clears tokens and falls back to
/login; a networkblip keeps the tokens for the next attempt.
first use.
Web (PWA) is untouched — it uses the
carol_sessioncookie andensureFreshAccessToken()is a no-op there.Verification
apps/clienttypecheck, eslint, and vitest all clean: 131 tests pass(20 files), including a new
tests/refresh.test.tscovering:fresh-token short-circuit, refresh-on-expiry + persisted new pair,
skew-window refresh, legacy-no-expiry, 401 → clearTokens → null, network
error → tokens retained, logged-out no-op, single-flight (concurrent calls
→ one fetch), and the web no-op. Plus storage round-trip tests for the new
expiry key and updated OAuth-flow expectation.
Manual end-to-end (device/emulator): sign in, wait past 15 min (or set
ACCESS_TOKEN_TTL_SECONDSlow), navigate — requests should refreshtransparently with no return to
/login; reopening the app after >15 min landson the home screen.
🤖 Generated with Claude Code
📊 Test coverage
Patch coverage: no testable lines changed.
Overall (
app/,lib/,db/, excluding UI per ADR-0019):Soft thresholds per ADR-0019. Coverage is informational and does not block merge.