Skip to content

Fix mobile auth-refresh failure: prevent 401 conversation retry loop and misleading signed-in UI #9375

Description

@Git-on-my-level

Summary

A mobile user on TestFlight build 992 reached a broken authenticated state: the app shell and cached greeting remained visible, but conversation data could not load. The production conversation-list endpoint returned repeated 401 Unauthorized responses, and the client retried indefinitely instead of resolving to a clear reauthentication state.

This is not a build-992/TestFlight routing regression. The relevant iOS environment/auth/conversation paths did not change between TestFlight builds 990 and 992; the code involved predates both releases. TestFlight is where the symptom was reported, not the proven cause.

User impact

The experience looks like data loss:

  • User sees the normal signed-in shell and cached name (for example, Hey Nik).
  • Conversations/data do not appear because /v1/conversations is unauthorized.
  • The UI keeps retrying instead of explaining that the session needs recovery/re-login.

Server data is not deleted; it is inaccessible to the client while its session is unusable.

Production evidence (sanitized)

During the incident window that overlaps the supplied TestFlight screenshot:

Observation Value
Window 2026-07-10 03:00:03Z–03:20:46Z
Matching requests 81
Endpoint GET /v1/conversations?include_discarded=false&limit=50&offset=0&statuses=
Response 401 Unauthorized
Cadence median 15.2 seconds (min 1.8s, max 18.3s)
Client user agent Dart/3.9 (dart:io)
Server revision backend-d1bb4c6-1

No correlated backend auth/deployment error was found in the same window. This is not evidence of a broad backend outage.

Root-cause chain

  1. A previously signed-in Firebase user needs a forced ID-token refresh:
    FirebaseAuth.instance.currentUser?.getIdTokenResult(true)
  2. AuthService.getIdToken() returns null for a generic Firebase/SDK/network refresh failure, calling it transient.
  3. If the cached token is expired, getAuthHeader() clears it; the HTTP header builder catches AuthTokenUnavailableException and proceeds without Authorization.
  4. The production API correctly returns 401.
  5. The generic HTTP 401 handler tries another refresh, then calls AuthService.signOut() if no usable token is available.
  6. The conversation provider sees a failed fetch and schedules retries. After initial backoff it continues every 15 seconds, including after terminal auth failure.

The initial Firebase refresh error is not observable from Cloud Run logs, so this issue must instrument it. Possibilities include a transient refresh/network failure, revoked credential, Keychain persistence problem, or Firebase SDK/platform failure. Do not assume TestFlight is itself logging users out without client evidence.

Relevant implementation / history

1. Missing token becomes an unauthenticated request

2c714a451c / PR #6848, Matt Van Horn:

  • introduced AuthTokenUnavailableException handling in app/lib/backend/http/shared.dart;
  • intentionally avoids the prior crash by continuing without Authorization;
  • relies on the downstream 401 path to recover authentication.

This was a reasonable crash mitigation, but it lacks a complete recovery contract.

2. Refresh failure is explicitly categorized as transient, then escalated to sign-out

f545a30ba7, beastoin:

} catch (e) {
  Logger.debug('getIdToken: token refresh failed (transient): $e');
  return null;
}

The HTTP 401 path subsequently turns that null into a sign-out attempt. This inconsistent transient-vs-terminal policy is the main auth-recovery defect.

3. Conversation retry makes terminal auth loss look like missing data

  • 3d27bc7471, Mohammed Mohsin: failed initial conversation fetches retain cache and auto-retry.
  • 892ef467a8, Mohammed Mohsin: after fast retries, continue retrying every 15 seconds.

The observed production request cadence matches this 15-second path.

4. Stale identity makes the session look valid

AuthService._clearCachedAuth() clears only authToken and tokenExpirationTime; cached profile values such as givenName are retained. Therefore a greeting can remain visible when the server session is unusable.

Scope

Fix both linked failure modes as one auth-recovery change:

  1. Token-refresh failure must not degrade into anonymous API traffic.
  2. A terminal auth failure must stop conversation retries and transition the UI to a clear recovery/re-login state.

Proposed implementation direction

  • Introduce a typed result/error that distinguishes:
    • token refresh transient failure;
    • missing token;
    • terminal/revoked/invalid session;
    • backend-rejected refreshed token.
  • Do not make authenticated API requests when no token is available.
  • For transient refresh failure, use a bounded auth-refresh retry state; do not call signOut() solely because a single refresh attempt returned null.
  • For confirmed terminal 401 after refresh:
    • sign out once;
    • cancel conversation retry timers and pending authenticated work;
    • clear user-scoped UI/cache state as appropriate;
    • route to sign-in and show a user-safe Session expired — sign in again message.
  • Ensure the app cannot keep rendering an apparently authenticated shell after a completed sign-out.
  • Preserve the previous crash-prevention benefit of fix(app): guard getAuthHeader against missing token (#6142) #6848: no unhandled async auth-header exception.

Acceptance criteria

  • A signed-in user whose forced token refresh transiently fails does not send Authorization: Bearer or no-auth requests to authenticated endpoints.
  • A terminal token failure or a second 401 after refresh signs out exactly once and presents sign-in/recovery UI.
  • Conversation fetching does not schedule retries after a terminal auth failure; no 15-second /v1/conversations 401 loop.
  • The app does not present a stale signed-in identity/home shell after sign-out.
  • Telemetry records a privacy-safe auth_token_refresh_failed event with failure class/code, platform, app version/build, and release channel; it must not include token, UID, email, or request body.
  • Telemetry records authenticated_request_401 and whether refresh/re-auth recovered.
  • Tests cover: refresh success, transient refresh failure, missing token, terminal Firebase auth error, backend 401 after refresh, sign-out UI transition, and cancelled conversation retries.
  • Add a signed TestFlight/iOS auth smoke or canary covering persisted login, forced token refresh, and authenticated conversation-list access.

Validation notes

  • TestFlight 992 source: 820379296 (Bump build number to 992 for mobile release #9329).
  • The relevant auth/retry commits above are ancestors of both builds 990 and 992.
  • No relevant auth/conversation/environment-routing paths changed in the 990 → 992 diff.
  • Existing unit coverage tests the local missing-header guard and token-cache branches, but not the full end-to-end failure mode: generic refresh failure → 401 → sign-out/UI transition → no provider retry loop.

Related historical context

  • #499 identified token-expiry/silent-sign-in correctness as a mobile requirement, but is closed and does not cover this current failure chain.
  • #6848 is the intentional crash-prevention change whose recovery path needs completion.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingflutterflutter workmobilep1Priority: Critical (score 22-29)stability

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions