Skip to content

fix(app): recover live-listen expired tokens - #12306

Open
arhxam wants to merge 2 commits into
BasedHardware:mainfrom
arhxam:fix/live-listen-auth-close-recovery
Open

fix(app): recover live-listen expired tokens#12306
arhxam wants to merge 2 commits into
BasedHardware:mainfrom
arhxam:fix/live-listen-auth-close-recovery

Conversation

@arhxam

@arhxam arhxam commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What changed and why

Fix both sides of the live-listen expired-token loop:

  • getAuthHeader() now derives expiry from the cached Firebase JWT it is about to send. The token's exp claim wins over the separately persisted timestamp, so a dead token paired with stale future metadata is refreshed before the WebSocket upgrade instead of being rejected as HTTP 403 forever.
  • If a server transport does deliver auth close codes, recovery remains bounded and single-flight: refresh and reconnect on 4001 success, retain the existing keepalive retry for transient failures, and expire the session for terminal/missing credentials or 4004 rejection.

Malformed or legacy non-JWT cached values retain the existing persisted-expiry fallback.

Closes #11694

Product invariants affected

none

How it was verified

  • flutter test test/unit/auth_token_expiration_test.dart test/unit/live_listen_auth_recovery_test.dart test/unit/backend/http/shared_test.dart test/unit/auth_service_token_result_test.dart test/unit/token_refresh_loop_test.dart — all 58 auth/header/recovery tests passed.
  • Targeted flutter analyze over the changed auth, capture, and test files — no issues.
  • The original capture-controller suite verification remains 89/89 passing.
  • A production token-expiry session was not forced against the live service; the stale-metadata failure and close-code/result matrix are covered hermetically.

Tests

  • Added regression coverage proving an expired JWT overrides stale future expiry metadata.
  • Added coverage for refresh success, transient failure, terminal failure, missing user, 4004 rejection, and ordinary transport closes.
  • Ran the existing capture-controller provider suite.
  • Live backend token-expiry exercise (would require disrupting a real account session).

Failure-Class: none

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @arhxam — the recovery module itself is cleanly built, but I found a wire-level gap that I believe prevents this from actually fixing #11694, so I'm requesting changes for now.

Core problem: the 4001/4004 close codes never reach onClosed in the failure mode this PR targets.

  • backend/utils/other/endpoints.py raises WebSocketException(code=4001/4004) inside _verify_ws_auth, which runs as a FastAPI dependency of get_current_user_uid_ws_listen — i.e. before websocket.accept().
  • With the pinned server stack (uvicorn==0.30.5, websockets==12.0 in backend/requirements.txt), a close sent before the handshake completes is answered as HTTP 403 with the close code dropped (uvicorn's websockets_impl.py maps a pre-accept websocket.close to HTTPStatus.FORBIDDEN). That matches the prod logs quoted in #11694 exactly: "WebSocket /v4/listen?... 403".
  • On the client, a rejected upgrade never produces a close frame: channel.ready fails with a WebSocketChannelException, PureSocket.connect() (app/lib/services/sockets/pure_socket.dart) catches it and returns falseonClosed is never called. So _scheduleLiveListenAuthRecovery in capture_controller.dart never fires for handshake rejections, and the retry storm in #11694 continues unchanged.
  • I also searched the backend for any post-accept producer of 4001/4004 and found none: WS_AUTH_CODE_TOKEN_REFRESH/WS_AUTH_CODE_RELOGIN_REQUIRED are only used on the pre-accept path; accepted-socket closes use 1008/1003/1011 (listen runtime) or 4005/4006 (deletion/cutover fence). The backend unit tests (backend/tests/unit/test_ws_auth_handshake.py) pass because Starlette's TestClient preserves close-before-accept codes, while a real uvicorn websockets-protocol server does not.

Net: live_listen_auth_recovery.dart handles codes the current client+server pair cannot deliver for this bug.

Per file:

  • app/lib/services/capture/capture_controller.dart — the single-flight guard (_liveListenAuthRecoveryInFlight + identical check) and the disposition switch are well done, and re-arming _startKeepAliveServices() on retryLater/notAuthRelated preserves the existing backoff; closeCode! is safe after the equality checks. The gap is only trigger reachability. One behavior worth a maintainer eye: 4004 now signs the user out immediately (expireSession) — reasonable for a revoked token, but it's a product behavior change on an auth surface.
  • app/lib/services/sockets/live_listen_auth_recovery.dart — good pure design (DI of refreshToken/expireSession, exhaustive sealed-class switch over AuthTokenResult). If/when the codes actually arrive, this is the right shape — it's just unreachable today for the handshake-rejection scenario.
  • app/test/unit/live_listen_auth_recovery_test.dart — solid matrix (success/transient/terminal/missing-user/4004/ordinary close, including asserting 4004 skips refresh). Missing the case that matters for #11694: an upgrade rejected with HTTP 403 delivers no close code at all and bypasses this module entirely.

Directions that would actually close #11694 (either, or coordinated):

  1. Client: handle the real signal — force AuthService.instance.refreshIdToken() after a bounded number of consecutive PureSocket.connect() failures on an authed socket, or fix the stale-expiry gate the issue names as root cause: getAuthHeader() in app/lib/backend/http/shared.dart trusts the cached tokenExpirationTime pref and skips refresh when it is stale-but-in-the-future.
  2. Backend: make /v4/listen deliver the codes — accept first, then close with 4001/4004 (or use the websocket denial-response extension). That's a coordinated protocol change, and test_ws_auth_handshake.py should keep modeling the real wire behavior.

Your recovery module slots straight into option 2 (and partly option 1), so this work isn't wasted — it's just ahead of the transport.

Maintainer note: beyond the contributor fix, this needs a maintainer decision on fix layering for #11694 (client refresh gate vs backend accept-then-close), and a quick sign-off on the new sign-out-on-4004 behavior since it changes an auth/session-expiry flow.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added security-review Touches auth, provider routing, secrets, or security-sensitive surfaces flutter flutter work labels Aug 27, 2026
@arhxam arhxam changed the title fix(app): recover live-listen auth closes fix(app): recover live-listen expired tokens Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

flutter flutter work security-review Touches auth, provider routing, secrets, or security-sensitive surfaces

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

2 participants