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

@@ -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("""<script>window.__HERMES_SESSION_TOKEN__ = "fresh-token";</script>"""),
)
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"))
}
}
@@ -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("""<script>window.__HERMES_SESSION_TOKEN__ = "new-token";</script>"""),
)
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("<html></html>"))
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("""<script>window.__HERMES_SESSION_TOKEN__ = "";</script>"""),
)
val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient())
assertNull(token)
}
}