From 907d24731218c3e89ad805de54a176a75b581345 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:58:58 +0300 Subject: [PATCH] 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 --- .../remote/DashboardSessionTokenRefresher.kt | 51 ++++++++++++++ .../data/remote/TokenRefreshAuthenticator.kt | 12 +++- .../hermescontrol/data/ws/HermesWsClient.kt | 16 ++++- .../data/remote/ApiClientTest.kt | 29 ++++++++ .../DashboardSessionTokenRefresherTest.kt | 70 +++++++++++++++++++ 5 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresher.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresherTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresher.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresher.kt new file mode 100644 index 00000000..ad450144 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresher.kt @@ -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 +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/TokenRefreshAuthenticator.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/TokenRefreshAuthenticator.kt index b0456b3c..c7bdfa9d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/TokenRefreshAuthenticator.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/TokenRefreshAuthenticator.kt @@ -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() } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 894c4492..62b9ffd5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -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 } diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt index a1c9d591..deac9e7a 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ApiClientTest.kt @@ -138,4 +138,33 @@ class ApiClientTest { val request = mockWebServer.takeRequest() assertEquals("Bearer $rawToken", request.getHeader("Authorization")) } + + @Test + fun testAuthInterceptor_refreshesExpiredDashboardTokenAndRetries() = + runTest { + var savedToken = "expired-token" + every { AuthManager.getToken() } answers { savedToken } + every { AuthManager.setToken(any()) } answers { savedToken = firstArg() } + every { AuthManager.getSessionCookie() } returns null + + ApiClient.rebuild() + + mockWebServer.enqueue(MockResponse().setResponseCode(401)) + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(""""""), + ) + mockWebServer.enqueue( + MockResponse().setResponseCode(200).setBody("""{"sessions":[],"total":0}"""), + ) + + val response = ApiClient.hermesApi.getSessions() + + assertTrue(response.isSuccessful) + assertEquals("fresh-token", savedToken) + assertEquals("Bearer expired-token", mockWebServer.takeRequest().getHeader("Authorization")) + assertEquals("/", mockWebServer.takeRequest().path) + assertEquals("Bearer fresh-token", mockWebServer.takeRequest().getHeader("Authorization")) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresherTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresherTest.kt new file mode 100644 index 00000000..8a877134 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresherTest.kt @@ -0,0 +1,70 @@ +package com.m57.hermescontrol.data.remote + +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class DashboardSessionTokenRefresherTest { + private lateinit var server: MockWebServer + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + } + + @AfterEach + fun tearDown() { + server.shutdown() + } + + @Test + fun fetchExtractsInjectedDashboardToken() { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(""""""), + ) + + val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient()) + + assertEquals("new-token", token) + assertEquals("/", server.takeRequest().path) + } + + @Test + fun fetchReturnsNullWhenDashboardDoesNotInjectToken() { + server.enqueue(MockResponse().setResponseCode(200).setBody("")) + + val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient()) + + assertNull(token) + } + + @Test + fun fetchReturnsNullForFailedResponse() { + server.enqueue(MockResponse().setResponseCode(500)) + + val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient()) + + assertNull(token) + } + + @Test + fun fetchReturnsNullWhenTokenIsBlank() { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(""""""), + ) + + val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient()) + + assertNull(token) + } +}