Skip to content

fix(mobile): stop launch sign-out on unreadable refresh token - #6604

Merged
iscekic merged 1 commit into
mainfrom
kwf/unexpected-signout-on-launch-bb35
Sep 23, 2026
Merged

iscekic merged 1 commit into
mainfrom
kwf/unexpected-signout-on-launch-bb35

Conversation

@iscekic

@iscekic iscekic commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

Changelog for users

  • A launch that cannot read the saved refresh token no longer signs you out. The app keeps your session and shows the retryable "Could not load your account" screen.
  • Tapping Retry on that screen recovers to the signed-in app once storage is readable.
  • A session the server really ended still signs you out, and the login screen shows "Your session ended. Please sign in again."

Changelog for maintainers

  • Cause: a null refresh-token read was treated as an absent session. The tRPC unauthorized handler signs out when refresh refuses. A WHEN_UNLOCKED_THIS_DEVICE_ONLY keychain item can answer null before first unlock.
  • SQLite is not the cause: credentials are read and written only through expo-secure-store. The SQLCipher kilo-persist.db holds only the persisted query cache, so a lock there costs a cache read, not a session.
  • A null refresh-token read is now retried on the existing 250/500/1000 ms cadence. Only a server 401 returns refused.
  • A retry that still answers null while a credential member is present returns unreadable with the present storage-key names. The unauthorized handler shows the restore-error screen and records branch refresh_token_unreadable, not a sign-out.
  • An empty credential set raises no restore error, and neither does a sign-out that owns the tree. The login route is never covered by a false error overlay.
  • A server-refused refresh is still the only refusal: it signs out with cause session_ended and branch refresh_401. An explicit sign-out records branch explicit. The login screen's ended-session announcement is locked by a regression test.
  • Telemetry writes one warning row per actual sign-out with message auth branch. Tags carry auth.cause, auth.branch, and auth.keys (storage-key names only), never a token value.
  • Review first credentials.ts doRefresh, the auth-context.tsx unauthorized handler, and the new sign-out-telemetry.ts. Device proof for the fault-window restore path and the revoked-session message is outstanding.

E2E proof

Owner request

Surface: mobile-app

Fix an unexpected sign-out on launch. Users report that the app sometimes asks them to log in
again when they start it. The owner asks whether SQLite locking causes it. Start by finding the
real cause from evidence, then fix it. Do not guess, and do not "fix" a path you cannot show.

The sign-out path, already traced

There is exactly one place that signs a healthy user out. Do not add a second one.

apps/mobile/src/lib/auth/auth-context.tsx:571-595 — the tRPC unauthorized handler:

const outcome = await performRefresh();
if (outcome.ok && isCurrentAuthEpoch(outcome.sessionVersion)) { ... return; }
if (!outcome.ok && outcome.refused && !isSignedOutReference.current) {
  await signOut(true);
}

The proactive foreground refresh at auth-context.tsx:597-645 deliberately does not sign out on
a refusal; it waits for a real 401. So a launch-time sign-out means a real authenticated request
met a 401 and the refresh was refused.

apps/mobile/src/lib/auth/credentials.ts:128-176 — doRefresh() returns refused: true in exactly
two branches:

  1. if (!storedRefreshToken) { return { ok: false, refused: true }; } — the refresh token was
    absent from storage.
  2. if (response.status === 401) { return { ok: false, refused: true }; } — the server refused the
    refresh token as expired or revoked.

Every other failure — a non-OK status, a malformed body, a thrown error, the
CONTROL_PLANE_DEADLINE_MS deadline — returns refused: false, which keeps the session. So the
question to answer is narrow: which of those two branches fires, and why.

The SQLite hypothesis is not supported, so test it and say so

Credentials do not live in SQLite. Say so in the PR body, with the evidence, or refute it with
evidence.

  • apps/mobile/src/lib/auth/credentials.ts:1 and apps/mobile/src/lib/auth/credentials.ts:80-118
    write the token, the refresh token, and the expiry through expo-secure-store.
  • apps/mobile/src/lib/auth/secure-store-value.ts is a single SecureStore.getItemAsync. There is
    no SQLite fallback in the read path.
  • apps/mobile/src/lib/auth/account-metadata-write.ts also writes SecureStore.
  • SQLite is kilo-persist.db through apps/mobile/src/lib/persist/encrypted-kv.ts (SQLCipher, WAL,
    busy_timeout = 5000), and it holds the persisted query cache
    (apps/mobile/src/lib/persist/read-cache.ts). A lock there costs a cache read, not a session.
  • The history is relevant: the database is locked reports (KILO-APP-7K / KILO-APP-5J / KILO-APP-5H)
    that encrypted-kv.ts:40-43 and :145-152 already answer were cache failures.

Candidate causes to test

Test each, and report the evidence that keeps or kills it.

  1. A transient null read of the refresh token. apps/mobile/src/lib/auth/secure-store-read.ts
    retries only a rejection: "Only a rejection is retried; a null resolution is a real answer
    (nothing stored) and returns immediately." A keychain read of a
    WHEN_UNLOCKED_THIS_DEVICE_ONLY item
    (credentials.ts:14-20) can answer null while the device is not yet unlocked, for example on a
    launch or a background wake before first unlock. That null becomes branch 1, and the user is
    signed out with valid credentials still on the device. This is the strongest candidate.
  2. A partially written credential set. credentials.ts:80-118 commits the token, then the
    refresh token, then the expiry, one write at a time. A process kill between the first and the
    second leaves a stored token with no refresh token. On the next launch, bootstrap restores the
    token, and the first 401 reaches branch 1.
  3. A refresh-rotation race. The server rotates the refresh token. If the response is lost, or if
    a second client presents the superseded token, the next refresh gets 401 (branch 2). The
    single-flight lock in credentials.ts:57-58 and performRefresh is per-process only, so a
    process restart mid-rotation can present a token the server has already rotated. Follow the
    evidence to the server if this is the cause: the fix may belong in the refresh endpoint's reuse
    handling, not the client.
  4. Anything else the evidence shows. Say what you ruled out.

The user gets no explanation today

login.sessionEnded — "Your session ended. Please sign in again." — exists in all 87 catalogs and
is unused in code. rg -n "sessionEnded" apps/mobile/src --glob '!**/locales/**' returns
nothing. So a genuine revocation drops the person on the login screen with no reason given, which
is a large part of the complaint.

Required result

  • Name the cause with evidence: a log line, a reproduction, or a test that fails on today's code.
  • Stop the false sign-outs. A null read of the refresh token while a token is present is a failed
    read, not a real state. Treat the credential set as one unit: read the token, the refresh token,
    and the expiry together, and never conclude "no session" from one absent member. Retry a null
    the same way the code already retries a rejection, and if it still fails, surface the existing
    restore-error screen with its Retry affordance
    (auth-context.tsx:168-175, restoreFailed) instead of signing out.
  • Keep a genuine, server-confirmed revocation a sign-out. Do not mask a real 401.
  • Tell the person why when the session really ended. Use login.sessionEnded for the case where
    the server refused the session, so the login screen is not silent.
  • Make the rate measurable: log the branch that fired and the reason, and record one telemetry row
    per sign-out with its cause, so a future report can be answered from data.
  • Never log a token, a token prefix, or a refresh token. Log the branch, the key name, and the
    outcome only.

Files

Start here; follow the evidence where it leads.

  • apps/mobile/src/lib/auth/credentials.ts — doRefresh, the two refusal branches.
  • apps/mobile/src/lib/auth/auth-context.tsx — the unauthorized handler, bootstrap, signOut.
  • apps/mobile/src/lib/auth/secure-store-read.ts and secure-store-value.ts — the read path.
  • apps/mobile/src/lib/auth/token-owner.ts — the active token and the single-flight lock.
  • apps/mobile/src/lib/auth/logout-cleanup.ts — what a sign-out does.
  • apps/mobile/src/components/login-screen.tsx — where a session-ended message would show.
  • apps/mobile/src/i18n/locales/en.json — the unused login.sessionEnded string.

Do not edit a file outside apps/mobile unless the evidence puts the cause in the backend. If the
cause is server-side, say so and change the server file the evidence names.

Proof

Prove the fix on a real launch, and prove the failure mode it removes.

  • The build carries an E2E fault hook for exactly this state:
    apps/mobile/src/lib/config.ts exposes E2E_SECURE_STORE_FAULT_MS, and
    secure-store-read.ts:20-31 rejects every read while its window is open. Use it, or an equivalent
    deterministic hook, to make the restore failure reproducible on a live build. Do not add a new
    hook if this one already covers the case.
  • Reproduce the failure on the unpatched code, then show the same scenario after the fix. Quote the
    decisive log lines from both runs.
  • Prove the good path: a normal launch with stored credentials reaches the signed-in app and issues
    no refresh-driven sign-out. Quote the log lines.
  • Prove the ended-session path shows login.sessionEnded, with a screenshot.
  • Quote the telemetry row the fix writes for one sign-out, with its cause field.
  • State the residual risk: what a real revocation still does, and why that is correct.

Keep pnpm --filter @kilocode/mobile test green, plus the mounted suite the section runbook names.
Add a test that fails on the old behaviour: a null refresh-token read with a present token must
not produce a sign-out.

[e1] ux-check: Server-confirmed revocation — login screen plus login.sessionEnded toast — Server-confirmed revocation (device_refresh_tokens cleared, NEXTAUTH_SECRET rotated then restored, nextjs restarted via fault.sh): the cold launch landed on the login screen — 'SCENE e1 OK' with 'android.widget.TextView Welcome to Kilo' and 'android.widget.TextView Your session ended. Please sign in again.' in e1-sessionended-scene3.log, and the refresh endpoint answered 'POST /api/auth/native/refresh 401 in 39ms' in e1-final-cap.log; capture e1.png for the visual reviewer; UX-DEFECT: none on the login screen or the toast.

[e1] ux-check: Server-confirmed revocation — login screen plus login.sessionEnded toast — scripted-shard1/e1.png

[e3] Launch with stored credentials and the E2E secure-store fault window open — android: cold launch with the secure-store fault window open shows 'Could not load your account' with 'Retry loading account' and 'Sign out' and no 'Welcome to Kilo' (e3-scene.log: 'SCENE e3 OK', 'Could not load your account', 'Retry loading account', 'Sign out'), and tapping Retry after the window reaches 'Home, tab, 1 of 3' (e3-retry.log: 'SCENE e3-retry OK'), so the person was not sent to login and credentials were preserved; the fault window was injected with a temporary E2E_SECURE_STORE_FAULT_MS hardcode in apps/mobile/src/lib/config.ts (reverted, worktree clean); no functional UX defect…

[e3] Launch with stored credentials and the E2E secure-store fault window open — prior/e3.png

[e3] Launch with stored credentials and the E2E secure-store fault window open

[e3] Launch with stored credentials and the E2E secure-store fault window open — prior/e3-restore-error.png

[e6] Normal launch with stored credentials and no fault — android: cold launch with stored credentials and no fault lands on the signed-in Home tab with the login screen never shown and no session-ended toast (e6-scene.log: 'SCENE e6 OK', 'LIVE NOW', 'Home, tab, 1 of 3', with 'Your session ended' and 'Welcome to Kilo' absent); no functional UX defect observed, visual audit deferred to the visual reviewer (screenshot e6.png).

[e6] Normal launch with stored credentials and no fault — prior/e6.png

[e2] ux-check: Signed-out launch stays on the login screen with no 'Could not load your account' overlay — Signed-out launch (no stored credentials) stayed on the login screen with no restore overlay — 'SCENE e2 OK' with 'android.widget.TextView Welcome to Kilo' in e2-signedout-scene.log, and also OK with nextjs down in e2-fault-scene.log; the 'authRequired 401' sub-condition was not firable from the launch (a token-less app issues no authRequired request) and is evidenced only from this run's sign-out aftermath (user.getMe 401 handled on the login screen with no overlay); capture e2.png; UX-DEFECT: none.

[e2] ux-check: Signed-out launch stays on the login screen with no 'Could not load your account' overlay — scripted-shard1/e2.png

[e4] ux-check: On that restore-error screen, tap Retry while storage still fails: the same screen stays mounted with the Retry button showing its inline spinner — no blank frame and no login screen. — android: restore-error screen reached with a temporary in-worktree secure-store read-failure fixture (E2E_SECURE_STORE_FAULT_MS was absent from Metro's process env this round; fixture reverted, git status --porcelain empty); e4-behavior.log carries the launch digest 'android.widget.TextView Could not load your account tappable [276,988][804,1053]', the idle pre-tap digest 'android.widget.Button Retry loading account tappable [55,1154][1025,1270]', and immediately after tapping Retry 'android.widget.Button Retry loading account, busy [55,1154][1025,1270]' at identical bounds, then…

[e4] ux-check: On that restore-error screen, tap Retry while storage still fails: the same screen stays mounted with the Retry button showing its inline spinner — no blank frame and no login screen. — prior/e4-pre.png

[e9] ux-check: Sign out from the app, then let an in-flight authRequired request settle: the login screen stays visible with no restore-error overlay, and no dead 'Sign out' control is presented. — android: from the signed-in Home start (e9-start.png, 'SCENE e9-start OK') the Agents tab was opened while 'fault.sh: stalled pids [422571] for 70s' held authRequired requests in flight, Profile -> Sign Out was confirmed ('android.widget.TextView Sign out? tappable [133,1065][947,1136]'), and after 'fault.sh: resumed pid 422571' the only nodes on screen were the login screen's — 'SCENE e9-final OK' with 'android.widget.TextView Welcome to Kilo tappable [387,686][692,751]' and no restore-error node and no Sign out node in that digest (e9-final.png); UX audit of Home/Profile/login: zero…

[e9] ux-check: Sign out from the app, then let an in-flight authRequired request settle: the login screen stays visible with no restore-error overlay, and no dead 'Sign out' control is presented. — e9-final.png

[e4] ux-check: On that restore-error screen, tap Retry while storage still fails: the same screen stays mounted with the Retry button showing its inline spinner — no blank frame and no login screen.

[e4] ux-check: On that restore-error screen, tap Retry while storage still fails: the same screen stays mounted with the Retry button showing its inline spinner — no blank frame and no login screen. — e4-idle2.png

[e9] ux-check: Sign out from the app, then let an in-flight authRequired request settle: the login screen stays visible with no restore-error overlay, and no dead 'Sign out' control is presented.

[e9] ux-check: Sign out from the app, then let an in-flight authRequired request settle: the login screen stays visible with no restore-error overlay, and no dead 'Sign out' control is presented. — e9-start.png

[e5] ux-check: On that restore-error screen, tap Retry after storage recovers: the signed-in app is revealed with no login screen and no session-ended toast. — Restore-error state produced with the build's own E2E secure-store fault hook hardcoded to 8000 ms (round Metro carried no such env; hardcode reverted): the log's digest shows 'Could not load your account' / 'Something went wrong' / 'Retry loading account' / 'Sign out' under 'SCENE e5-error OK', then 'SCENE e5 OK' with the digest 'Home, tab, 1 of 3' after a single Retry tap once the window elapsed, with no 'Your session ended' state shown; no UX-DEFECT observed in the digest (screenshots e5-error.png, e5.png captured for the visual reviewer).

[e5] ux-check: On that restore-error screen, tap Retry after storage recovers: the signed-in app is revealed with no login screen and no session-ended toast. — e5.png

[e4] ux-check: On that restore-error screen, tap Retry while storage still fails: the same screen stays mounted with the Retry button showing its inline spinner — no blank frame and no login screen.

[e4] ux-check: On that restore-error screen, tap Retry while storage still fails: the same screen stays mounted with the Retry button showing its inline spinner — no blank frame and no login screen. — e4-tapnew.png

[e4] ux-check: On that restore-error screen, tap Retry while storage still fails: the same screen stays mounted with the Retry button showing its inline spinner — no blank frame and no login screen.

[e4] ux-check: On that restore-error screen, tap Retry while storage still fails: the same screen stays mounted with the Retry button showing its inline spinner — no blank frame and no login screen. — e4-after-retry.png

[e5] ux-check: On that restore-error screen, tap Retry after storage recovers: the signed-in app is revealed with no login screen and no session-ended toast.

[e5] ux-check: On that restore-error screen, tap Retry after storage recovers: the signed-in app is revealed with no login screen and no session-ended toast. — e5-error.png

[e1] ux-check: Server-confirmed revocation — login screen plus login.sessionEnded toast

[e1] ux-check: Server-confirmed revocation — login screen plus login.sessionEnded toast — e1.png

[e2] ux-check: Signed-out launch stays on the login screen with no 'Could not load your account' overlay

[e2] ux-check: Signed-out launch stays on the login screen with no 'Could not load your account' overlay — e2.png

Open findings (not fixed here)

  • no stored credentials shows the login screen and no restore error: expected /Welcome to [Kk]ilo( Code)?/, with Could not load your account ab ...[699 more chars]
    mobile-device: signed-in app data restored on emulator-5554
    mobile-device: signed-in app data frozen on emulator-5554 (68392448 bytes)

Surface: mobile-app

Fix an unexpected sign-out on launch. Users report that the app sometimes asks them to log in
again when they start it. The owner asks whether SQLite locking causes it. Start by finding the
real cause from evidence, then fix it. Do not guess, and do not "fix" a path you cannot show.

## The sign-out path, already traced

There is exactly one place that signs a healthy user out. Do not add a second one.

`apps/mobile/src/lib/auth/auth-context.tsx:571-595` — the tRPC unauthorized handler:

```
const outcome = await performRefresh();
if (outcome.ok && isCurrentAuthEpoch(outcome.sessionVersion)) { ... return; }
if (!outcome.ok && outcome.refused && !isSignedOutReference.current) {
  await signOut(true);
}
```

The proactive foreground refresh at `auth-context.tsx:597-645` deliberately does **not** sign out on
a refusal; it waits for a real 401. So a launch-time sign-out means a real authenticated request
met a 401 and the refresh was refused.

`apps/mobile/src/lib/auth/credentials.ts:128-176` — `doRefresh()` returns `refused: true` in exactly
two branches:

1. `if (!storedRefreshToken) { return { ok: false, refused: true }; }` — the refresh token was
   absent from storage.
2. `if (response.status === 401) { return { ok: false, refused: true }; }` — the server refused the
   refresh token as expired or revoked.

Every other failure — a non-OK status, a malformed body, a thrown error, the
`CONTROL_PLANE_DEADLINE_MS` deadline — returns `refused: false`, which k
@iscekic
iscekic marked this pull request as draft September 23, 2026 00:10
@kilo-code-bot

kilo-code-bot Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Executive Summary

The mobile auth changes are internally consistent and well covered: an unreadable (null) refresh-token read is now retried and reported as unreadable/credentials_unreadable without signing out, empty credential sets never raise the restore error, and a server 401 remains the only refusal (session_ended/refresh_401). The telemetry payload carries only branch/cause/storage-key names, never token values.

Files Reviewed (10 files)
  • apps/mobile/src/components/login-screen.test.ts
  • apps/mobile/src/lib/auth/auth-context.lifecycle.test.tsx
  • apps/mobile/src/lib/auth/auth-context.test.ts
  • apps/mobile/src/lib/auth/auth-context.tsx
  • apps/mobile/src/lib/auth/credentials.test.ts
  • apps/mobile/src/lib/auth/credentials.ts
  • apps/mobile/src/lib/auth/secure-store-read.test.ts
  • apps/mobile/src/lib/auth/secure-store-read.ts
  • apps/mobile/src/lib/auth/sign-out-telemetry.test.ts
  • apps/mobile/src/lib/auth/sign-out-telemetry.ts

Reviewed by deepseek-v4.1-flash · Input: 0 · Output: 0 · Cached: 0

Review guidance: REVIEW.md from base branch main

@iscekic
iscekic marked this pull request as ready for review September 23, 2026 00:26
@iscekic iscekic added the human-ready The PR is ready for human review. label Sep 23, 2026
@iscekic iscekic self-assigned this Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

human-ready The PR is ready for human review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants