fix(client): keep the Android app logged in via proactive token refresh (#306) #307

Merged
james merged 1 commit from native-stay-logged-in into main 2026-06-27 00:30:07 +00:00
Owner

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 the
access token verbatim; once it expired the API 401'd and the protected layout
redirected to /login. getRefreshToken() was defined but never called
anywhere. The server side (POST /api/auth/refresh, ADR-0027 §6) was already
complete.

Fix — proactive, single-flight refresh

  • Persist the expiry. setTokens now stores accessExpiresAt alongside
    the tokens; storage.ts caches it in memory next to the access token.
  • lib/auth/refresh.ts (new) — ensureFreshAccessToken(). Returns a
    usable access token, refreshing via POST /api/auth/refresh first when the
    current one is missing, expired, or within a 30s skew of expiry.
    getAuthHeader() now calls it, so refresh happens before the request goes
    out (no fragile 401-retry / request-body replay on React Native).
  • Single-flight. All callers share one in-flight promise, so concurrent
    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).
  • No re-entrancy. The refresh uses a bare fetch (not the openapi-fetch
    client), so it can't loop back through getAuthHeader() and deadlock, and it
    needs no bearer header.
  • Failure handling. A dead refresh token (expired >30d / revoked /
    reuse-detected → 401) clears tokens and falls back to /login; a network
    blip keeps the tokens for the next attempt.
  • Legacy installs that stored a token before the expiry key existed refresh on
    first use.

Web (PWA) is untouched — it uses the carol_session cookie and
ensureFreshAccessToken() is a no-op there.

Verification

  • apps/client typecheck, eslint, and vitest all clean: 131 tests pass
    (20 files), including a new tests/refresh.test.ts covering:
    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_SECONDS low), navigate — requests should refresh
transparently with no return to /login; reopening the app after >15 min lands
on the home screen.

🤖 Generated with Claude Code

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 the access token verbatim; once it expired the API 401'd and the protected layout redirected to `/login`. `getRefreshToken()` was defined but never called anywhere. The server side (`POST /api/auth/refresh`, ADR-0027 §6) was already complete. ## Fix — proactive, single-flight refresh - **Persist the expiry.** `setTokens` now stores `accessExpiresAt` alongside the tokens; `storage.ts` caches it in memory next to the access token. - **`lib/auth/refresh.ts` (new) — `ensureFreshAccessToken()`.** Returns a usable access token, refreshing via `POST /api/auth/refresh` first when the current one is missing, expired, or within a 30s skew of expiry. `getAuthHeader()` now calls it, so refresh happens *before* the request goes out (no fragile 401-retry / request-body replay on React Native). - **Single-flight.** All callers share one in-flight promise, so concurrent 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). - **No re-entrancy.** The refresh uses a bare `fetch` (not the openapi-fetch client), so it can't loop back through `getAuthHeader()` and deadlock, and it needs no bearer header. - **Failure handling.** A dead refresh token (expired >30d / revoked / reuse-detected → 401) clears tokens and falls back to `/login`; a network blip keeps the tokens for the next attempt. - Legacy installs that stored a token before the expiry key existed refresh on first use. Web (PWA) is untouched — it uses the `carol_session` cookie and `ensureFreshAccessToken()` is a no-op there. ## Verification - `apps/client` typecheck, eslint, and vitest all clean: **131 tests pass** (20 files), including a new `tests/refresh.test.ts` covering: 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_SECONDS` low), navigate — requests should refresh transparently with no return to `/login`; reopening the app after >15 min lands on the home screen. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(client): keep native session alive via proactive token refresh
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 12s
PR / Static analysis (pull_request) Successful in 1m41s
PR / Lint (pull_request) Successful in 3m19s
PR / Typecheck (pull_request) Successful in 3m26s
PR / Build (pull_request) Successful in 3m37s
PR / OpenAPI (pull_request) Successful in 3m50s
PR / Test (postgres) (pull_request) Successful in 3m35s
PR / Client (web export smoke) (pull_request) Successful in 3m54s
PR / pnpm audit (pull_request) Successful in 2m16s
PR / OSV-Scanner (pull_request) Successful in 47s
PR / Test (sqlite) (pull_request) Successful in 2m49s
PR / Package age policy (soft) (pull_request) Successful in 45s
Secrets / gitleaks (pull_request) Successful in 46s
PR / Coverage (soft) (pull_request) Successful in 2m26s
PR / Trivy (image) (pull_request) Successful in 3m3s
7b378d10d9
The native client minted a 15-min access token plus a 30-day refresh
token but never used the refresh token: getAuthHeader() attached the
access token verbatim, so once it expired every request 401'd and the
protected layout bounced the user to /login — roughly every 15 minutes
and on every app reopen, despite a valid refresh token in SecureStore.

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

- Persist the access token's expiry (accessExpiresAt) alongside the
  tokens in SecureStore.
- New lib/auth/refresh.ts: ensureFreshAccessToken() returns a usable
  access token, refreshing via POST /api/auth/refresh first when the
  current one is missing, expired, or within a 30s skew of expiry.
  getAuthHeader() now calls it.
- Single-flight: all callers share one in-flight refresh promise, so
  concurrent requests at expiry fire 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.
- The refresh call is a bare fetch (not the openapi-fetch client) so it
  can't re-enter getAuthHeader() and deadlock, and needs no bearer.
- A dead refresh token (expired >30d / revoked / reuse-detected) clears
  tokens and falls back to /login; a network blip keeps them.

Web (PWA) is untouched — it authenticates with the carol_session
cookie, and ensureFreshAccessToken() is a no-op there.

Closes #306

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 81.6% ≥ 50%
Branches 72.8% ⚠️ ≥ 75%
Functions 91.1% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 81.6% ✅ | ≥ 50% | | Branches | 72.8% ⚠️ | ≥ 75% | | Functions | 91.1% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit ba24b4c790 into main 2026-06-27 00:30:07 +00:00
james deleted branch native-stay-logged-in 2026-06-27 00:30:07 +00:00
Sign in to join this conversation.
No description provided.