fix(app): guard getAuthHeader against missing token (#6142)#6848
fix(app): guard getAuthHeader against missing token (#6142)#6848mdmohsin7 merged 1 commit intoBasedHardware:mainfrom
Conversation
On v1.0.526, getAuthHeader was throwing a plain Exception('No auth token
found') when the user appeared signed-in but SharedPreferencesUtil had
no cached token. The exception propagated through buildHeaders as an
unhandled async error and crashed the app — ~3,400 Crashlytics fatals
across ~83 users between Mar 22-27.
Wrap the error path in a typed AuthTokenUnavailableException and catch
it at the sole in-file caller (buildHeaders). On catch, log at debug
level and proceed without the Authorization header, letting the normal
backend 401 handling drive re-auth rather than taking down the app.
No changes to AuthService — getIdToken already force-refreshes via
getIdTokenResult(true). No new HTTP wrapping. No other call sites
modified (the four existing `getIdToken() ?? ''` sites already handle
the null case).
Fixes BasedHardware#6142
Greptile SummaryThis PR converts the bare Confidence Score: 5/5Safe to merge — the fix is minimal and correctly eliminates the crash while preserving existing auth-recovery semantics. All remaining findings are P2: the test file exercises the exception shape via simulation stubs rather than the real implementation, which is a coverage gap but not a correctness defect. The production code change itself is correct and low-risk. app/test/unit/backend/http/shared_test.dart — tests cover simulation helpers, not the actual Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller
participant buildHeaders
participant getAuthHeader
participant SharedPrefs
participant AuthService
Caller->>buildHeaders: requireAuthCheck: true
buildHeaders->>getAuthHeader: await
getAuthHeader->>SharedPrefs: authToken
SharedPrefs-->>getAuthHeader: "" (empty)
getAuthHeader->>AuthService: isSignedIn()
AuthService-->>getAuthHeader: true
alt Before this PR
getAuthHeader-->>buildHeaders: throw Exception("No auth token found")
buildHeaders-->>Caller: ❌ unhandled crash (Crashlytics fatal)
else After this PR
getAuthHeader-->>buildHeaders: throw AuthTokenUnavailableException
buildHeaders->>buildHeaders: catch → Logger.debug(...)
buildHeaders-->>Caller: headers (no Authorization key)
Caller->>Caller: HTTP request → 401
Caller->>AuthService: signOut() (existing recovery path)
end
Reviews (1): Last reviewed commit: "fix(app): guard getAuthHeader against mi..." | Re-trigger Greptile |
| Future<String> simulateGetAuthHeader({required bool isSignedIn, required String token}) async { | ||
| if (token.isEmpty && isSignedIn) { | ||
| throw AuthTokenUnavailableException('No auth token found'); | ||
| } | ||
| return 'Bearer $token'; | ||
| } | ||
|
|
||
| Future<Map<String, String>> simulateBuildHeaders({required Future<String> Function() getAuthHeader}) async { | ||
| final headers = <String, String>{}; | ||
| try { | ||
| headers['Authorization'] = await getAuthHeader(); | ||
| } on AuthTokenUnavailableException { | ||
| // Mirrors buildHeaders() behavior in shared.dart: continue without auth header. | ||
| } | ||
| return headers; | ||
| } |
There was a problem hiding this comment.
Tests simulate rather than exercise real implementation
The tests define local simulateGetAuthHeader and simulateBuildHeaders stubs that mirror the logic in shared.dart but never call the actual buildHeaders() or getAuthHeader(). As a result, the catch block added at line 77 of shared.dart — the core of this fix — has zero test coverage. If a future refactor accidentally removes the on AuthTokenUnavailableException handler, these tests would still pass.
Consider replacing the simulation approach with a test that injects a mock AuthService returning isSignedIn: true and a mock SharedPreferencesUtil returning an empty token, so the real buildHeaders(requireAuthCheck: true) path is exercised end-to-end.
|
Thanks for the merge, @mdmohsin7. Guarding getAuthHeader against missing tokens is the right call. |
…) (BasedHardware#6848) ## Summary On v1.0.526, `getAuthHeader()` in `app/lib/backend/http/shared.dart` throws a plain `Exception('No auth token found')` when the user appears signed-in but `SharedPreferencesUtil` has no cached token. The exception propagates through the async header-builder as an unhandled error and crashes the app. Crashlytics recorded ~3,400 fatals across ~83 users between Mar 22-27, 2026. This PR converts the throw into a typed `AuthTokenUnavailableException` and catches it at the sole in-file caller (`buildHeaders`), logging the condition and letting the request proceed without an Authorization header. ## Why this matters `getAuthHeader()` sits on every authenticated HTTP path in the app. A raw `throw Exception(...)` is not something most async callers guard against, so a single user in this bad state generates hundreds of crash reports. The crash count is high (3,400) despite the user count being modest (83), which confirms the exception fires repeatedly as the app retries work. ## Changes Introduce `AuthTokenUnavailableException` at the top of `shared.dart` and replace the generic throw with this typed variant. The check logic itself is unchanged: we only throw when the token is empty AND `AuthService.isSignedIn()` is true, so the semantics that "signed-in-but-no-token is a signal worth surfacing" are preserved - we just stop representing that signal as an unhandled crash. At the sole in-file caller (`buildHeaders` around the `requireAuthCheck` branch), the `await getAuthHeader()` is wrapped in a `try/on AuthTokenUnavailableException` that logs via `Logger.debug` and proceeds without the Authorization header. The downstream HTTP 401 path in `makeApiCall` already calls `AuthService.signOut()`, so recovery still happens where it was already wired - we're not adding a new sign-out path, we're just removing the crash that was preventing the 401 path from being reached. The four other sites that already guard `getIdToken() ?? ''` (lines 121, 256, 328, 448) are unchanged - they were never at risk because they already handle the null case. ## Testing A small unit test at `app/test/unit/backend/http/shared_test.dart` covers the typed exception and the caller-guard pattern: exception raises on signed-in-empty-token, caller catches it and omits `Authorization`, happy path still attaches `Bearer <token>`. Tests use `flutter_test` and don't require Firebase/SharedPreferences mocks because they exercise the exception shape directly. I did not run `flutter test` locally (flutter SDK not installed in this environment). `dart format --line-length 120` clean on both files. ## Limitations - WebSocket handshake paths (`PureSocket.connect`, `PhoneCallProvider._connectTranscriptionSocket`) use `buildHeaders(requireAuthCheck: true)` but won't see a 401, so a signed-in-no-token user could see socket connections fail silently instead of a crash. Still a net improvement, and the existing HTTP paths will trigger re-auth. - No change to `AuthService`. If the token is persistently missing despite `getIdTokenResult(true)` returning non-null, that's an underlying auth-state bug worth its own issue. Fixes BasedHardware#6142 This contribution was developed with AI assistance (Codex).
Summary
On v1.0.526,
getAuthHeader()inapp/lib/backend/http/shared.dartthrows a plainException('No auth token found')when the user appears signed-in butSharedPreferencesUtilhas no cached token. The exception propagates through the async header-builder as an unhandled error and crashes the app. Crashlytics recorded ~3,400 fatals across ~83 users between Mar 22-27, 2026.This PR converts the throw into a typed
AuthTokenUnavailableExceptionand catches it at the sole in-file caller (buildHeaders), logging the condition and letting the request proceed without an Authorization header.Why this matters
getAuthHeader()sits on every authenticated HTTP path in the app. A rawthrow Exception(...)is not something most async callers guard against, so a single user in this bad state generates hundreds of crash reports. The crash count is high (3,400) despite the user count being modest (83), which confirms the exception fires repeatedly as the app retries work.Changes
Introduce
AuthTokenUnavailableExceptionat the top ofshared.dartand replace the generic throw with this typed variant. The check logic itself is unchanged: we only throw when the token is empty ANDAuthService.isSignedIn()is true, so the semantics that "signed-in-but-no-token is a signal worth surfacing" are preserved - we just stop representing that signal as an unhandled crash.At the sole in-file caller (
buildHeadersaround therequireAuthCheckbranch), theawait getAuthHeader()is wrapped in atry/on AuthTokenUnavailableExceptionthat logs viaLogger.debugand proceeds without the Authorization header. The downstream HTTP 401 path inmakeApiCallalready callsAuthService.signOut(), so recovery still happens where it was already wired - we're not adding a new sign-out path, we're just removing the crash that was preventing the 401 path from being reached.The four other sites that already guard
getIdToken() ?? ''(lines 121, 256, 328, 448) are unchanged - they were never at risk because they already handle the null case.Testing
A small unit test at
app/test/unit/backend/http/shared_test.dartcovers the typed exception and the caller-guard pattern: exception raises on signed-in-empty-token, caller catches it and omitsAuthorization, happy path still attachesBearer <token>. Tests useflutter_testand don't require Firebase/SharedPreferences mocks because they exercise the exception shape directly.I did not run
flutter testlocally (flutter SDK not installed in this environment).dart format --line-length 120clean on both files.Limitations
PureSocket.connect,PhoneCallProvider._connectTranscriptionSocket) usebuildHeaders(requireAuthCheck: true)but won't see a 401, so a signed-in-no-token user could see socket connections fail silently instead of a crash. Still a net improvement, and the existing HTTP paths will trigger re-auth.AuthService. If the token is persistently missing despitegetIdTokenResult(true)returning non-null, that's an underlying auth-state bug worth its own issue.Fixes #6142
This contribution was developed with AI assistance (Codex).