fix: refresh loopback dashboard token after restart (#568)

* fix: refresh loopback dashboard token after restart

The gateway regenerates its ephemeral session token on every restart, so the
token the app persisted becomes stale. After a dashboard restart REST calls
401 and the WebSocket auto-reconnect loop hangs in AUTH_EXPIRED until the user
manually re-authenticates.

- Add DashboardSessionTokenRefresher: re-reads the injected
  window.__HERMES_SESSION_TOKEN__ from the dashboard SPA and writes it back to
  AuthManager.
- TokenRefreshAuthenticator: on a 401 retry, scrape a fresh token from the
  dashboard and retry instead of giving up.
- HermesWsClient: refresh the token before each non-gated WebSocket handshake so
  reconnects don't get stuck on a stale token; guard serverStore read against
  IllegalStateException.

Covers the loopback (--insecure) deployment path; gated/ticket mode is untouched.

* fix: harden dashboard token refresher (sync, body cap, blank-token test)

Second-opinion review flagged robustness gaps (no correctness/security
regressions in the happy path):

- DashboardSessionTokenRefresher.refresh(): @Synchronized to dedupe
  concurrent 401 / reconnect storms instead of each firing its own GET.
- Cap SPA HTML read at 64 KiB before regex scan (defensive against OOM on
  low-end devices); we only need the injected token near the document top.
- HermesWsClient: log (not silently swallow) the transient uninitialized
  serverStore case so a real misconfig is still visible.
- Add test: blank-injected token returns null.

---------

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-13 00:58:58 +03:00
1 parent ee28ee5ce2
commit 907d247312
5 files changed
+174 -4

No files matched your search

@@ -4,6 +4,7 @@ import android.util.Log
import androidx.annotation.VisibleForTesting
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.CompletableDeferred
@@ -234,8 +235,21 @@ object HermesWsClient {
* and ticket refresh failed or cannot be performed.
*/
private fun refreshWsTicketIfNeeded(): Boolean {
val isGated = AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
val isGated =
try {
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
} catch (_: IllegalStateException) {
// serverStore not initialized yet (transient early call); treat
// as loopback so a stale-token refresh still runs. Log so a real
// misconfiguration isn't silently swallowed.
Log.w(TAG, "serverStore uninitialized during WS handshake; assuming non-gated")
false
}
if (!isGated) {
// The loopback dashboard token is regenerated on every server
// restart. Refresh it before each WebSocket handshake so automatic
// reconnect does not get stuck in AUTH_EXPIRED with a stale token.
DashboardSessionTokenRefresher.refresh()
return true
}