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:
- Firebase rotates signing key (e.g.,
2c27aff5 rotated Jun 2-3)
- User's cached ID token (signed with old key) is now invalid
- App reconnects →
getAuthHeader() detects token expired → calls getIdToken()
getIdToken() returns null (user's refresh token also expired — requires re-login)
refreshedToken is null → SharedPreferences NOT updated → stale token stays in cache
hasAuthToken = SharedPreferencesUtil().authToken.isNotEmpty → true (old token still there)
!hasAuthToken check at line 49 does NOT trigger → AuthTokenUnavailableException NOT thrown
- Returns
Bearer <stale_expired_token>
- 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-57 — getAuthHeader()
app/lib/backend/preferences.dart — SharedPreferencesUtil().authToken
Investigation by @mon (runtime signals) and @taro (code tracing) for @beastoin
Related Issues & Incident Data
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:2c27aff5rotated Jun 2-3)getAuthHeader()detects token expired → callsgetIdToken()getIdToken()returnsnull(user's refresh token also expired — requires re-login)refreshedTokenis null → SharedPreferences NOT updated → stale token stays in cachehasAuthToken = SharedPreferencesUtil().authToken.isNotEmpty→true(old token still there)!hasAuthTokencheck at line 49 does NOT trigger →AuthTokenUnavailableExceptionNOT thrownBearer <stale_expired_token>The null check at line 49 only guards against EMPTY tokens, not STALE tokens.
Runtime Evidence
2c27aff5(no longer in Google public key list)Proposed Fix
When refresh was attempted AND failed, clear the stale cached token:
This makes the null-token path reach
AuthTokenUnavailableExceptionat line 49-54, whichbuildHeaders()catches. The existing 401→signOut path then activates, forcing re-login.Files
app/lib/backend/http/shared.dart:33-57—getAuthHeader()app/lib/backend/preferences.dart—SharedPreferencesUtil().authTokenInvestigation by @mon (runtime signals) and @taro (code tracing) for @beastoin
Related Issues & Incident Data