Skip to content

Flutter: getAuthHeader() returns stale cached token when Firebase refresh fails #7631

@beastoin

Description

@beastoin

Summary

When getIdToken() returns null (Firebase refresh fails), getAuthHeader() falls through to the stale cached token in SharedPreferences instead of clearing it. This causes infinite retry loops with an invalid token.

Root Cause

app/lib/backend/http/shared.dart:33-57 — exact flow:

  1. Firebase rotates signing key (e.g., 2c27aff5 rotated Jun 2-3)
  2. User's cached ID token (signed with old key) is now invalid
  3. App reconnects → getAuthHeader() detects token expired → calls getIdToken()
  4. getIdToken() returns null (user's refresh token also expired — requires re-login)
  5. refreshedToken is null → SharedPreferences NOT updated → stale token stays in cache
  6. hasAuthToken = SharedPreferencesUtil().authToken.isNotEmptytrue (old token still there)
  7. !hasAuthToken check at line 49 does NOT trigger → AuthTokenUnavailableException NOT thrown
  8. Returns Bearer <stale_expired_token>
  9. Backend validates → "Certificate not found" / "Token expired" → close 1008 → retry → same stale token → infinite loop

The null check at line 49 only guards against EMPTY tokens, not STALE tokens.

Runtime Evidence

  • 14 users stuck in retry loop since Jun 3 (Firebase key rotation)
  • Stale tokens signed with rotated key 2c27aff5 (no longer in Google public key list)
  • Tokens expired 3–44 hours, retrying every 15 seconds
  • ~5K wasted WebSocket attempts/day from these 14 users
  • Zero errors before Jun 3 — started exactly when key rotated

Proposed Fix

When refresh was attempted AND failed, clear the stale cached token:

if (!hasAuthToken || !isExpirationDateValid) {
    final refreshedToken = await AuthService.instance.getIdToken();
    if (refreshedToken != null) {
        SharedPreferencesUtil().authToken = refreshedToken;
    } else if (!isExpirationDateValid) {
        // Refresh failed AND token is known expired — don't use stale token
        SharedPreferencesUtil().authToken = '';
    }
    hasAuthToken = SharedPreferencesUtil().authToken.isNotEmpty;
}

This makes the null-token path reach AuthTokenUnavailableException at line 49-54, which buildHeaders() catches. The existing 401→signOut path then activates, forcing re-login.

Files

  • app/lib/backend/http/shared.dart:33-57getAuthHeader()
  • app/lib/backend/preferences.dartSharedPreferencesUtil().authToken

Investigation by @mon (runtime signals) and @taro (code tracing) for @beastoin


Related Issues & Incident Data

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions