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)
+ }
+}