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
|
// shared CookieJar (issue #470), so OkHttp re-attaches it on retry
|
||||||
// without any manual header manipulation here.
|
// without any manual header manipulation here.
|
||||||
|
|
||||||
// Loopback mode: re-assert the Bearer token if the request lacked it
|
// Loopback mode: a dashboard restart invalidates the saved ephemeral
|
||||||
// (or it drifted from the current stored token).
|
// 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 token = AuthManager.getToken()
|
||||||
val requestAuth = response.request.header("Authorization")
|
val requestAuth = response.request.header("Authorization")
|
||||||
val currentAuthHeader = "Bearer $token"
|
val currentAuthHeader = "Bearer $token"
|
||||||
@@ -29,6 +30,11 @@ object TokenRefreshAuthenticator : Authenticator {
|
|||||||
.header("Authorization", currentAuthHeader)
|
.header("Authorization", currentAuthHeader)
|
||||||
.build()
|
.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 androidx.annotation.VisibleForTesting
|
||||||
import com.m57.hermescontrol.BuildConfig
|
import com.m57.hermescontrol.BuildConfig
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
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.NetworkMonitor
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
@@ -234,8 +235,21 @@ object HermesWsClient {
|
|||||||
* and ticket refresh failed or cannot be performed.
|
* and ticket refresh failed or cannot be performed.
|
||||||
*/
|
*/
|
||||||
private fun refreshWsTicketIfNeeded(): Boolean {
|
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) {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,4 +138,33 @@ class ApiClientTest {
|
|||||||
val request = mockWebServer.takeRequest()
|
val request = mockWebServer.takeRequest()
|
||||||
assertEquals("Bearer $rawToken", request.getHeader("Authorization"))
|
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