Skip to content

fix(app): refresh the auth token the server rejects as expired - #12433

Merged
mdmohsin7 merged 5 commits into
mainfrom
fix/app-refresh-token-on-expired-socket
Aug 30, 2026
Merged

fix(app): refresh the auth token the server rejects as expired#12433
mdmohsin7 merged 5 commits into
mainfrom
fix/app-refresh-token-on-expired-socket

Conversation

@mdmohsin7

@mdmohsin7 mdmohsin7 commented Aug 30, 2026

Copy link
Copy Markdown
Member

Fixes #11694.

Summary

Prod backend-listen rejects live-transcription upgrades from clients presenting a token that expired days earlier:

WebSocket auth failed: code=4001 error=Token expired, 1786634034 < 1786900104

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. getAuthHeader gates the refresh on SharedPreferencesUtil().tokenExpirationTime, a separate stored copy of the expiry. refreshIdToken writes 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 ships ClockSkewDetector because 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 4001 is mapped in pure_socket.dart to the string 'auth_token_refresh_required' — for a log label. Nothing performed the refresh the constant names. capture_controller.onClosed handles 4002 (out of credits) and ignores 4001 entirely.

So every input the client consults says "no refresh needed", and the one input that says otherwise is only printed.

What changed

  • jwtExpiry() reads exp out of the token. A JWT carries its own expiry; a second copy beside it can only drift.
  • getAuthHeader prefers it, falling back to the cached value only when the token cannot be parsed — so an unreadable token is no worse off than today.
  • onClosed acts 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 parsing exp alone 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 used PRE_PUSH_SKIP_DART_FORMAT=1. Please run:

cd app && flutter test test/unit/jwt_expiry_test.dart

To avoid shipping parsing logic I could not execute, I reimplemented jwtExpiry in Python and ran every test vector through it: a valid exp decodes; a missing, non-integer, or zero exp, 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 has len % 4 == 3, which base64Url.decode rejects without the normalize call the implementation makes.

Also checked by hand: unawaited and Logger were already imported in capture_controller.dart, refreshIdToken persists 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 needs flutter pub get to 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 preflight passes.

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 4004 for 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

Review in cubic

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Live-listen auth: 15 clients stuck in a permanent expired-token reconnect loop — 372 rejected upgrades vs 333 accepted in 10 min

1 participant