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
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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