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

@@ -0,0 +1,51 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.Buffer
internal object DashboardSessionTokenRefresher {
private val tokenPattern = Regex("""__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"""")
// Synchronized so concurrent 401s / reconnect storms don't each fire a
// redundant GET at the dashboard SPA (thundering-herd guard).
@Synchronized
fun refresh(): String? =
try {
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
AuthManager.setToken(token)
token
} catch (_: Exception) {
null
}
internal fun fetch(
baseUrl: String,
client: OkHttpClient,
): String? =
try {
val request = Request.Builder().url(baseUrl).get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
// Cap the read — the SPA HTML can be large, and we only need the
// injected token near the top of the document. Reading the whole
// body into a String risks OOM on low-end devices.
val html =
response.body.source().use { source ->
val buffer = Buffer()
source.read(buffer, MAX_BODY_BYTES)
buffer.readUtf8()
}
tokenPattern
.find(html)
?.groupValues
?.getOrNull(1)
?.takeIf { it.isNotBlank() }
}
} catch (_: Exception) {
null
}
private const val MAX_BODY_BYTES: Long = 64 * 1024
}
@@ -18,8 +18,9 @@ object TokenRefreshAuthenticator : Authenticator {
// shared CookieJar (issue #470), so OkHttp re-attaches it on retry
// without any manual header manipulation here.
// Loopback mode: re-assert the Bearer token if the request lacked it
// (or it drifted from the current stored token).
// Loopback mode: a dashboard restart invalidates the saved ephemeral
// token. First reuse a token another request may already have refreshed;
// otherwise extract the current token from the dashboard SPA.
val token = AuthManager.getToken()
val requestAuth = response.request.header("Authorization")
val currentAuthHeader = "Bearer $token"
@@ -29,6 +30,11 @@ object TokenRefreshAuthenticator : Authenticator {
.header("Authorization", currentAuthHeader)
.build()
}
return null
val refreshedToken = DashboardSessionTokenRefresher.refresh() ?: return null
return response.request
.newBuilder()
.header("Authorization", "Bearer $refreshedToken")
.build()
}
}
@@ -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
}