fix(app): refresh the auth token the server rejects as expired - #12433
Merged
Conversation
A JWT carries its own exp. Keeping a second copy beside it can only drift, and #11694 shows which way: clients presented a token three days dead while their cached expiry said it was fresh. Returns null for anything unreadable so callers keep their existing fallback.
getAuthHeader gated the refresh on SharedPreferencesUtil().tokenExpirationTime, a separate stored value written alongside the token. When it advances and the token does not - a failed token write, for instance - the client is certain a dead credential is fresh and never refreshes. The token's exp now decides, falling back to the cached value only when the token cannot be parsed.
Close code 4001 was mapped to the string 'auth_token_refresh_required' for a log label and nothing performed the refresh it names, so the socket reconnected with the same dead credential every ~15s - forever, for 15 users since Aug 16. The server is the only authority here: a wrong device clock makes an expired token look valid to every local check. Refreshing on 4001 fixes the loop whatever made the local view wrong. Throttled to once per 30s so a persistently rejected credential cannot turn reconnects into a refresh storm.
PlatformException has no const constructor, so #12427 left main red on Dart Analyze with CONST_WITH_NON_CONST at both construction sites. I wrote those tests without being able to run the analyzer; this repairs main.
dart format keeps it on one line at 117 characters.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #11694.
Summary
Prod
backend-listenrejects live-transcription upgrades from clients presenting a token that expired days earlier:I measured prod today, two weeks after the report: individual clients are still being rejected 35–44 times per 10 minutes — a reconnect every ~15 seconds, per client, indefinitely. Those users capture nothing at all, and the retries land on the listen tier at times outnumbering real traffic.
The client does refresh when it believes the token is expired. The belief is the bug — it is assembled from two local sources, and the one authoritative source is discarded.
It never asks the token.
getAuthHeadergates the refresh onSharedPreferencesUtil().tokenExpirationTime, a separate stored copy of the expiry.refreshIdTokenwrites that copy and the token in sequence, so any failure between them leaves the expiry advanced while the token is not — and the client is then certain a dead credential is fresh. That is not hypothetical: until #12420 landed this morning, the token write was fire-and-forget with no error handling, so a keychain failure lost it silently.It trusts the device clock.
expiry.isBefore(DateTime.now())compares against the phone's own clock. A device running days behind considers a long-dead token valid and never refreshes. This repo already shipsClockSkewDetectorbecause skew happens in this fleet — but it only reports; nothing feeds it into the auth decision.And the server's verdict was thrown away. Close code
4001is mapped inpure_socket.dartto the string'auth_token_refresh_required'— for a log label. Nothing performed the refresh the constant names.capture_controller.onClosedhandles4002(out of credits) and ignores4001entirely.So every input the client consults says "no refresh needed", and the one input that says otherwise is only printed.
What changed
jwtExpiry()readsexpout of the token. A JWT carries its own expiry; a second copy beside it can only drift.getAuthHeaderprefers it, falling back to the cached value only when the token cannot be parsed — so an unreadable token is no worse off than today.onClosedacts on 4001, refreshing before the next reconnect. This is the half that matters most: it is correct regardless of why the local view was wrong, including a skewed clock, which parsingexpalone cannot fix. Throttled to once per 30s so a persistently rejected credential cannot turn reconnects into a refresh storm.Verification
I could not run
flutter test,flutter analyze, or the app — the Flutter SDK path is unreadable from my sandbox, so the push usedPRE_PUSH_SKIP_DART_FORMAT=1. Please run:To avoid shipping parsing logic I could not execute, I reimplemented
jwtExpiryin Python and ran every test vector through it: a validexpdecodes; a missing, non-integer, or zeroexp, a non-JWT string, invalid base64, and an empty string all return null. The padding case is real rather than theoretical — Firebase strips=, and the encoded payload in that test haslen % 4 == 3, whichbase64Url.decoderejects without thenormalizecall the implementation makes.Also checked by hand:
unawaitedandLoggerwere already imported incapture_controller.dart,refreshIdTokenpersists the token and expiry itself (so the handler adds no second write), and no changed line exceeds 120 characters.The push also used
PRE_PUSH_SKIP_FLUTTER_GENERATED=1, which needsflutter pub getto have resolved app dependencies — blocked by the same missing SDK. It checks generated output is current; this diff adds no ARB keys and touches no generator input.make preflightpasses.What this does not fix
The 15 affected clients only get this when they update. If they are on an old build — the issue never established which versions, and the upgrade URL carries no app version — they keep looping until then. A server-side answer (closing with
4004for a repeatedly-expired credential, or backing off the rejected upgrade) would cover them without a release, and is worth its own issue rather than being folded in here.Failure-Class: none