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