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:
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"))
|
||||
}
|
||||
}
|
||||
+70
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user