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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user