refactor: proper OkHttp CookieJar integration (issue #470) (#483)

Replace the manual session-cookie interceptor in ApiClient and the
manual cookie branch in TokenRefreshAuthenticator with a shared
PersistentCookieJar backed by an encrypted, per-server EncryptedCookieStore.

- Add CookieStore iface + EncryptedCookieStore (EncryptedSharedPreferences,
  per-server scope, legacy single-cookie migration)
- Add CookieSerialization (OkHttp Cookie <-> holder) + PersistentCookieJar
  (in-mem cache, async persist, atomic useStore scope switching, prune,
  session-cookie accessor)
- Add CookieManager coordinator (init/useStore/prune/clear) and wire its
  cookieJar into OkHttpProvider base/websocket/probe (lazy)
- AuthManager.get/setSessionCookie now delegate to CookieManager; add
  pruneServerCache(); clear path drops the scope's session cookie
- AuthLoginViewModel + HermesWsClient rely on the jar for Set-Cookie capture
  and session-cookie re-attachment (no manual header injection)
- Add fakes (FakeEncryptedCookieStore/buildFakePersistentCookieJar) + unit
  tests (PersistentCookieJarTest, CookieManagerTest); inject fake jar into
  ApiClientTest/HermesWsClientTest setup

WS token-out-of-URL (step 5) deferred: backend hermes-agent authenticates
WS only via ws.query_params, so moving it requires a coordinated backend
change (noted for follow-up).

Verified: testDebugUnitTest (334 passing) + assembleDebug green via local SDK.
This commit is contained in:
M57 authored and GitHub committed 2026-07-07 21:51:04 +03:00
1 parent ce7048c07e
commit d178d7f69e
15 files changed
+953 -88

No files matched your search

@@ -11,6 +11,7 @@ import com.m57.hermescontrol.data.config.ServerStoreSerializer
import com.m57.hermescontrol.data.config.resolvedHost
import com.m57.hermescontrol.data.config.resolvedPort
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
@@ -96,16 +97,6 @@ object AuthManager {
val store = ServerStore(dataStore, scope)
_serverStore = store
scope.launch {
store.stateFlow.collect { state ->
_bottomNavItemsFlow.value = state.bottomNavItems
_themePreferenceFlow.value = state.themePreference
_useDynamicColorsFlow.value = state.useDynamicColors
_themePresetFlow.value = state.themePreset
_bottomNavDisplayModeFlow.value = state.bottomNavDisplayMode
}
}
if (prefsDeferred == null) {
prefsDeferred =
CoroutineScope(Dispatchers.IO).async {
@@ -123,6 +114,22 @@ object AuthManager {
p
}
}
// Initialize the encrypted cookie store (issue #470). The legacy
// session-cookie prefs are passed as a Deferred so existing gated
// sessions can be migrated on first load WITHOUT blocking startup.
// The Deferred is created above, before this call.
CookieManager.initialize(context, prefsDeferred)
scope.launch {
store.stateFlow.collect { state ->
_bottomNavItemsFlow.value = state.bottomNavItems
_themePreferenceFlow.value = state.themePreference
_useDynamicColorsFlow.value = state.useDynamicColors
_themePresetFlow.value = state.themePreset
_bottomNavDisplayModeFlow.value = state.bottomNavDisplayMode
}
}
}
}
@@ -155,13 +162,25 @@ object AuthManager {
/**
* In gated mode (basic auth), the dashboard authenticates REST API
* requests via the `hermes_session_at` cookie, not via
* `Authorization: *** We store it here so [ApiClient]'s
* authInterceptor can add it as a `Cookie` header.
* `Authorization: Bearer`. The cookie is now owned by the shared
* [CookieManager]/[PersistentCookieJar] (issue #470) which attaches it
* automatically on every REST call, follows redirects, and persists it
* encrypted. This accessor is a thin read-through to that store.
*/
fun getSessionCookie(): String? = requirePrefs().getString(KEY_SESSION_COOKIE, null)
fun getSessionCookie(): String? = CookieManager.getSessionCookie()
fun setSessionCookie(cookie: String?) {
requirePrefs().edit().putString(KEY_SESSION_COOKIE, cookie).apply()
// The session cookie is host-scoped to the current dashboard host so
// the CookieJar only attaches it to matching requests.
CookieManager.setSessionCookie(cookie, AuthManager.getHost())
}
/**
* Evict expired (non-session) cookies for the active server scope to
* bound cookie growth (issue #470 step 7).
*/
fun pruneServerCache() {
CookieManager.pruneServerCache()
}
// ── Database Master Password ─────────────────────────────────────────