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 ─────────────────────────────────────────
@@ -91,31 +91,22 @@ object ApiClient {
}
}
// Loopback mode: authenticate via Bearer token (the session cookie used
// in gated mode is now handled automatically by the shared CookieJar —
// see issue #470).
val authInterceptor =
Interceptor { chain ->
val request = chain.request()
val sessionCookie = AuthManager.getSessionCookie()
if (!sessionCookie.isNullOrBlank()) {
// Gated mode: authenticate via session cookie
val token = AuthManager.getToken()
if (!token.isNullOrBlank()) {
chain.proceed(
request
.newBuilder()
.addHeader("Cookie", "hermes_session_at=$sessionCookie")
.addHeader("Authorization", "Bearer $token")
.build(),
)
} else {
// Loopback mode: authenticate via Bearer token
val token = AuthManager.getToken()
if (!token.isNullOrBlank()) {
chain.proceed(
request
.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build(),
)
} else {
chain.proceed(request)
}
chain.proceed(request)
}
}
@@ -0,0 +1,119 @@
package com.m57.hermescontrol.data.remote
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
/**
* Coordinator for the issue #470 cookie stack.
*
* Owns the single [PersistentCookieJar] instance and exposes:
* - [cookieJar] — inject into every OkHttp client (REST + WS + probe).
* - [initialize] — build the encrypted store + jar (idempotent).
* - [useStore] — atomically switch the active server scope.
* - [pruneServerCache] / [clearAll] — lifecycle/maintenance.
* - [getSessionCookie] / [setSessionCookie] — backward-compatible accessors
* used by [com.m57.hermescontrol.data.local.AuthManager] and the login flow.
*
* The session cookie is still exposed as a single string because the WS client
* and a few REST paths reference it directly; internally it is just the
* `hermes_session_at` cookie from the active scope.
*/
object CookieManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var jar: PersistentCookieJar? = null
val cookieJar: PersistentCookieJar
get() = jar ?: error("CookieManager.initialize(context) must be called before use")
fun isInitialized(): Boolean = jar != null
fun initialize(
context: Context,
legacyPrefsDeferred: Deferred<SharedPreferences>? = null,
) {
if (jar != null) return
synchronized(this) {
if (jar != null) return
val store = EncryptedCookieStore(context.applicationContext, legacyPrefsDeferred)
jar = PersistentCookieJar(store, scope)
// Eagerly load the default scope off the caller thread so the
// first REST call is instant without blocking app startup.
scope.launch { jar!!.useStore(PersistentCookieJar.DEFAULT_SERVER_ID) }
}
}
/** Switch the active server scope and load its persisted cookies. */
fun useStore(serverId: String) {
val j = jar ?: return
runBlocking(scope.coroutineContext) { j.useStore(serverId) }
}
/** Read the current `hermes_session_at` cookie value (or null). */
fun getSessionCookie(): String? = jar?.getSessionCookieValue()
/**
* Set the `hermes_session_at` session cookie for the active scope.
* [host] should be the dashboard host so the cookie matches REST requests.
*/
fun setSessionCookie(
rawValue: String?,
host: String,
) {
val j = jar ?: return
if (rawValue.isNullOrBlank()) {
// Clearing: drop the session cookie (and any other cookies) for the
// active scope — equivalent to the old AuthManager nulling the
// session-cookie pref.
j.clearActive()
return
}
val cookie =
Cookie
.Builder()
.name(SESSION_COOKIE_NAME)
.value(rawValue)
.expiresAt(System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000)
.hostOnlyDomain(host)
.path("/")
.httpOnly()
.build()
val url = "http://$host/".toHttpUrl()
j.saveFromResponse(url, listOf(cookie))
}
/** Evict expired (non-session) cookies for the active scope. */
fun pruneServerCache() {
jar?.pruneServerCache(allScopes = false)
}
/** Wipe all cookies across all server scopes (logout / full reset). */
fun clearAll() {
jar?.clearAll()
}
// ── Test seam ────────────────────────────────────────────────────────
/**
* Replace the backing jar with [jar] (used by unit tests to inject a
* [FakePersistentCookieJar]). Not for production use.
*/
@Suppress("unused")
internal fun setJarForTest(jar: PersistentCookieJar?) {
this.jar = jar
}
/** Reset to uninitialized state (tests only). */
@Suppress("unused")
internal fun resetForTest() {
jar = null
}
}
@@ -0,0 +1,96 @@
package com.m57.hermescontrol.data.remote
import okhttp3.Cookie
import okhttp3.HttpUrl
/**
* Wire format for a persisted [Cookie].
*
* OkHttp's [Cookie] is not itself serializable, so we project its immutable
* fields into this flat holder. `sameSite` is nullable in newer OkHttp
* versions — default to null on decode so older payloads still load.
*/
@kotlinx.serialization.Serializable
data class CookieHolder(
val name: String,
val value: String,
val expiresAt: Long,
val domain: String,
val path: String,
val secure: Boolean,
val httpOnly: Boolean,
val hostOnly: Boolean,
val sameSite: String? = null,
)
/** Project a [Cookie] into its serializable form. */
fun Cookie.serialize(): CookieHolder =
CookieHolder(
name = name,
value = value,
expiresAt = expiresAt,
domain = domain,
path = path,
secure = secure,
httpOnly = httpOnly,
hostOnly = hostOnly,
sameSite = sameSite,
)
/** Rebuild a [Cookie] from a [CookieHolder]. */
fun CookieHolder.toCookie(): Cookie? =
runCatching {
Cookie
.Builder()
.name(name)
.value(value)
.expiresAt(expiresAt)
.apply { if (hostOnly) hostOnlyDomain(domain) else domain(domain) }
.path(path)
.also { if (secure) it.secure() }
.also { if (httpOnly) it.httpOnly() }
.also { if (sameSite != null) it.sameSite(sameSite) }
.build()
}.getOrNull()
/**
* Wrap a raw `hermes_session_at` value (the pre-#470 single-string session
* cookie) as a permissive session cookie. Host/path are left empty so it
* matches any request the jar sends — matching the previous behaviour where
* the value was injected as a bare `Cookie: hermes_session_at=<value>` header
* on every REST call.
*/
fun wrapSessionCookie(rawValue: String): Cookie? {
val value = rawValue.trim()
if (value.isEmpty()) return null
return runCatching {
Cookie
.Builder()
.name(SESSION_COOKIE_NAME)
.value(value)
// Far-future expiry (10 years) — session lifetime is owned by the
// dashboard, not by client-side cookie expiry.
.expiresAt(System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000)
.path("/")
.httpOnly()
.build()
}.getOrNull()
}
/**
* Build a permissive [Cookie] from a Set-Cookie `hermes_session_at=...`
* header captured during login. Used by [PersistentCookieJar] when the server
* does not echo an explicit domain (common for same-host dashboard logins).
*/
fun sessionCookieFromValue(value: String): Cookie? = wrapSessionCookie(value)
const val SESSION_COOKIE_NAME = "hermes_session_at"
/** True when [cookie] is the dashboard session cookie. */
fun isSessionCookie(cookie: Cookie): Boolean = cookie.name == SESSION_COOKIE_NAME
/** Parse a raw `Set-Cookie` header value (sans the `hermes_session_at=` key). */
fun Cookie.Companion.parseRaw(
url: HttpUrl,
header: String,
): Cookie? = Cookie.parse(url, header)
@@ -0,0 +1,134 @@
package com.m57.hermescontrol.data.remote
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Cookie
/**
* Persistence contract for server-scoped cookies.
*
* Implementations store cookies per logical server id (not the raw host) so
* that switching [PersistentCookieJar] scopes never lets cookies from one
* gateway leak into another (issue #470 — multi-server isolation).
*
* Splitting this into an interface lets unit tests inject an in-memory fake
* ([com.m57.hermescontrol.data.remote.FakeEncryptedCookieStore]) instead of
* spinning up [EncryptedSharedPreferences], which requires Android Keystore.
*/
interface CookieStore {
/** Persist [cookies] for [serverId]. Replaces the previous set atomically. */
suspend fun save(
serverId: String,
cookies: List<Cookie>,
)
/** Load the persisted cookie set for [serverId] (may be empty). */
suspend fun load(serverId: String): List<Cookie>
/** Drop all persisted cookies for [serverId] (e.g. on logout). */
suspend fun clear(serverId: String)
/** Drop everything across all servers. */
suspend fun clearAll()
}
/**
* Encrypted, per-server cookie persistence backed by
* [EncryptedSharedPreferences] (AES256-GCM). Cookies are serialized with
* kotlinx.serialization and written on [Dispatchers.IO].
*
* Legacy migration: the pre-#470 code stored a single raw
* `hermes_session_at` value in `AuthManager`'s prefs under
* [LEGACY_SESSION_COOKIE_KEY]. [load] transparently folds any such legacy
* value into the requested [serverId] scope on first read so existing gated
* sessions keep working without a re-login.
*/
class EncryptedCookieStore(
private val context: Context,
private val legacyPrefsDeferred: Deferred<SharedPreferences>? = null,
) : CookieStore {
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val masterKeyAlias by lazy { MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) }
private val prefs by lazy {
EncryptedSharedPreferences.create(
PREFS_FILE,
masterKeyAlias,
context.applicationContext,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
override suspend fun save(
serverId: String,
cookies: List<Cookie>,
) = withContext(Dispatchers.IO) {
val serialized = cookies.map { it.serialize() }
prefs.edit().putString(keyFor(serverId), json.encodeToString(serialized)).apply()
}
override suspend fun load(serverId: String): List<Cookie> =
withContext(Dispatchers.IO) {
// Fold in legacy single-cookie storage once per server scope.
// This awaits the deferred prefs on the IO dispatcher — never
// blocks the caller (e.g. main thread) during app startup.
val migrated = maybeMigrateLegacy(serverId)
val raw = prefs.getString(keyFor(serverId), null) ?: return@withContext migrated
runCatching {
json
.decodeFromString<List<CookieHolder>>(raw)
.mapNotNull { it.toCookie() }
.plus(migrated)
.distinctBy { Triple(it.name, it.domain, it.path) }
}.getOrDefault(migrated)
}
override suspend fun clear(serverId: String) =
withContext(Dispatchers.IO) {
prefs.edit().remove(keyFor(serverId)).apply()
}
override suspend fun clearAll() =
withContext(Dispatchers.IO) {
prefs.edit().clear().apply()
}
/**
* If a legacy `hermes_session_at` value exists and we have not yet folded
* it into [serverId], wrap it as a host-less session cookie and clear the
* legacy key so the migration is one-shot. Awaits the deferred legacy
* prefs on the IO dispatcher so [load] (already on IO) never blocks the
* caller thread.
*/
private suspend fun maybeMigrateLegacy(serverId: String): List<Cookie> {
val prefs =
legacyPrefsDeferred?.await()
?: return emptyList()
val legacy =
prefs.getString(LEGACY_SESSION_COOKIE_KEY, null)
?: return emptyList()
if (legacy.isBlank()) return emptyList()
prefs.edit().remove(LEGACY_SESSION_COOKIE_KEY).apply()
return wrapSessionCookie(legacy)?.let { listOf(it) } ?: emptyList()
}
private fun keyFor(serverId: String) = "cookies::$serverId"
companion object {
const val PREFS_FILE = "hermes_secure_cookies"
const val LEGACY_SESSION_COOKIE_KEY = "session_cookie"
}
}
@@ -12,36 +12,55 @@ object OkHttpProvider {
// 5 idle connections, 30s keep-alive — tuned for single-server LAN
private val connectionPool = ConnectionPool(5, 30, TimeUnit.SECONDS)
// Base client: connection pool + sensible defaults
// Note: retryOnConnectionFailure(true) is enabled to allow OkHttp to recover from
// low-level connection/route failures (e.g. route timeouts, IPv4/IPv6 fallback). This is
// separate from safeApiCall's app-level retries (which add backoff delays and handle 5xx/429/timeouts).
val base: OkHttpClient =
/**
* Shared [okhttp3.CookieJar] used by every client (REST, WS, probe) so a
* Set-Cookie from one request is reusable by the others (issue #470).
*
* Resolved lazily (per client build) so it is only touched after
* [CookieManager.initialize] has run at app startup — NOT at
* [OkHttpProvider] object-init time, which would otherwise throw for unit
* tests / early access before the app context exists.
*/
private fun resolveCookieJar(): okhttp3.CookieJar = CookieManager.cookieJar
// Base client: connection pool + sensible defaults.
// Lazily built so the shared CookieJar is only resolved at first use
// (after CookieManager.initialize), not during object construction.
// Note: retryOnConnectionFailure(true) lets OkHttp recover from low-level
// connection/route failures (route timeouts, IPv4/IPv6 fallback), separate
// from safeApiCall's app-level retries (backoff + 5xx/429/timeouts).
val base: OkHttpClient by lazy {
OkHttpClient
.Builder()
.cookieJar(resolveCookieJar())
.connectionPool(connectionPool)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
}
// WebSocket-optimized variant (infinite read timeout, ping interval)
val websocket: OkHttpClient =
val websocket: OkHttpClient by lazy {
base
.newBuilder()
.cookieJar(resolveCookieJar())
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(30, TimeUnit.SECONDS)
.build()
}
// Short-timeout variant for probes and ticket minting
val probe: OkHttpClient =
val probe: OkHttpClient by lazy {
base
.newBuilder()
.cookieJar(resolveCookieJar())
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.followRedirects(false)
.build()
}
@OptIn(ExperimentalSerializationApi::class)
val json: Json =
@@ -0,0 +1,177 @@
package com.m57.hermescontrol.data.remote
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
/**
* OkHttp [CookieJar] backed by an in-memory [ConcurrentHashMap] cache plus
* encrypted [CookieStore] persistence.
*
* Design notes (issue #470):
* - **Memory cache** keyed by host for O(1) reads on every request.
* - **Async persistence** — writes are dispatched to [storeScope] and never
* block the calling OkHttp thread.
* - **Atomic server scoping** via [useStore] — callers (login, profile switch)
* flip the active server id; subsequent load/save target that scope. Guarded
* by [scopeMutex] so a swap can't race a mid-flight load.
* - **Lazy load** — the first request for a server scope triggers a one-shot
* [CookieStore.load] (double-checked through [loadedScopes]).
* - **Pruning** — [pruneServerCache] evicts expired cookies to bound growth.
*
* The session cookie (`hermes_session_at`) is explicitly retained across
* pruning because its lifetime is owned server-side, not by client expiry.
*/
class PersistentCookieJar(
private val store: CookieStore,
private val storeScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) : CookieJar {
// serverId -> (host -> cookies)
private val cache = ConcurrentHashMap<String, MutableMap<String, MutableList<Cookie>>>()
private val loadedScopes = ConcurrentHashMap.newKeySet<String>()
@Volatile private var currentServerId = AtomicReference(DEFAULT_SERVER_ID)
private val scopeMutex = Mutex()
/** Atomically switch the active server scope and ensure its cookies are loaded. */
suspend fun useStore(serverId: String) {
scopeMutex.withLock {
currentServerId.set(serverId)
ensureLoaded(serverId)
}
}
/** Current active server scope id. */
fun currentServer(): String = currentServerId.get()
/**
* Return the live [Cookie] with [name] from the active server scope, or
* null if absent. Scans every host bucket; the session cookie is typically
* host-scoped to the dashboard host.
*/
fun getCookie(name: String): Cookie? {
val serverId = currentServerId.get()
val hosts = cache[serverId] ?: return null
for (bucket in hosts.values) {
synchronized(bucket) {
val match = bucket.firstOrNull { it.name == name }
if (match != null) return match
}
}
return null
}
/** Convenience: value of the `hermes_session_at` session cookie. */
fun getSessionCookieValue(): String? = getCookie(SESSION_COOKIE_NAME)?.value
override fun saveFromResponse(
url: HttpUrl,
cookies: List<Cookie>,
) {
if (cookies.isEmpty()) return
val serverId = currentServerId.get()
val hostKey = url.host
val bucket =
cache
.getOrPut(serverId) { ConcurrentHashMap() }
.getOrPut(hostKey) { mutableListOf() }
synchronized(bucket) {
for (cookie in cookies) {
val idx = bucket.indexOfFirst { it.name == cookie.name && it.path == cookie.path }
if (idx >= 0) bucket[idx] = cookie else bucket.add(cookie)
}
}
// Persist the whole server scope asynchronously.
persist(serverId)
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val serverId = currentServerId.get()
// Best-effort synchronous load on first touch (tests/first call).
if (!loadedScopes.contains(serverId)) {
kotlinx.coroutines.runBlocking { ensureLoaded(serverId) }
}
val hostKey = url.host
val hosts = cache[serverId] ?: return emptyList()
val now = System.currentTimeMillis()
val buckets = listOfNotNull(hosts[hostKey], hosts[WILDCARD_HOST])
synchronized(buckets) {
return buckets.flatMap { bucket ->
synchronized(bucket) {
bucket.filter { it.matches(url) && (it.expiresAt > now || isSessionCookie(it)) }
}
}
}
}
private suspend fun ensureLoaded(serverId: String) {
if (loadedScopes.contains(serverId)) return
val persisted = store.load(serverId)
val byHost = ConcurrentHashMap<String, MutableList<Cookie>>()
for (cookie in persisted) {
// Blank domain => host-only (e.g. legacy migrated session cookie).
// Bucket it under a wildcard "*" so it is returned for every host.
val host = cookie.domain.removePrefix(".").ifBlank { WILDCARD_HOST }
byHost.getOrPut(host) { mutableListOf() }.add(cookie)
}
cache[serverId] = byHost
loadedScopes.add(serverId)
}
private fun persist(serverId: String) {
val snapshot =
cache[serverId]?.values?.flatten()?.toList() ?: return
storeScope.launch { store.save(serverId, snapshot) }
}
/**
* Evict expired cookies for the active (or all) server scopes to prevent
* unbounded growth. Session cookies are preserved.
*/
fun pruneServerCache(allScopes: Boolean = false) {
val now = System.currentTimeMillis()
val scopeKeys =
if (allScopes) cache.keys.toList() else listOf(currentServerId.get())
for (scope in scopeKeys) {
val hosts = cache[scope] ?: continue
for ((host, bucket) in hosts) {
synchronized(bucket) {
bucket.removeAll { it.expiresAt <= now && !isSessionCookie(it) }
}
if (bucket.isEmpty()) hosts.remove(host)
}
persist(scope)
}
}
/** Clear the active server scope's in-memory + persisted cookies. */
fun clearActive() {
val scope = currentServerId.get()
cache.remove(scope)
loadedScopes.remove(scope)
storeScope.launch { store.clear(scope) }
}
/** Clear every server scope (logout / full reset). */
fun clearAll() {
cache.clear()
loadedScopes.clear()
storeScope.launch { store.clearAll() }
}
companion object {
const val DEFAULT_SERVER_ID = "default"
/** Bucket key for host-only (blank-domain) cookies, returned for any host. */
const val WILDCARD_HOST = "*"
}
}
@@ -14,26 +14,20 @@ object TokenRefreshAuthenticator : Authenticator {
if (response.priorResponse != null) {
return null // Only retry once
}
val sessionCookie = AuthManager.getSessionCookie()
if (!sessionCookie.isNullOrBlank()) {
val requestCookie = response.request.header("Cookie")
val currentCookieHeader = "hermes_session_at=$sessionCookie"
if (requestCookie == null || !requestCookie.contains(currentCookieHeader)) {
return response.request
.newBuilder()
.header("Cookie", currentCookieHeader)
.build()
}
} else {
val token = AuthManager.getToken()
val requestAuth = response.request.header("Authorization")
val currentAuthHeader = "Bearer $token"
if (!token.isNullOrBlank() && (requestAuth == null || !requestAuth.contains(currentAuthHeader))) {
return response.request
.newBuilder()
.header("Authorization", currentAuthHeader)
.build()
}
// Gated mode: the session cookie is now managed automatically by the
// 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).
val token = AuthManager.getToken()
val requestAuth = response.request.header("Authorization")
val currentAuthHeader = "Bearer $token"
if (!token.isNullOrBlank() && (requestAuth == null || !requestAuth.contains(currentAuthHeader))) {
return response.request
.newBuilder()
.header("Authorization", currentAuthHeader)
.build()
}
return null
}
@@ -176,6 +176,9 @@ object HermesWsClient {
* If a session cookie is present (gated mode), mint a fresh WS ticket
* from the dashboard. The ticket is single-use and has a 30-second TTL,
* so we must mint a new one on every connect (first launch and reconnect).
*
* The session cookie is attached automatically by the shared CookieJar on
* OkHttpProvider.probe (issue #470), so we no longer inject it manually.
*/
private fun refreshWsTicketIfNeeded() {
val sessionCookie = AuthManager.getSessionCookie()
@@ -188,13 +191,12 @@ object HermesWsClient {
Request
.Builder()
.url("http://${AuthManager.getHost()}:${AuthManager.getPort()}/api/auth/ws-ticket")
.header("Cookie", "hermes_session_at=$sessionCookie")
.post("{}".toRequestBody())
.build()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
val body = response.body?.string() ?: ""
val ticketMatch = Regex(""""ticket":"([^"]+)"""").find(body)
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\""").find(body)
val ticket = ticketMatch?.groupValues?.getOrNull(1)
if (!ticket.isNullOrBlank()) {
AuthManager.setToken(ticket)
@@ -258,8 +258,6 @@ class AuthLoginViewModel(
private data class ConnectResult(
/** WS credential: session token (loopback) or WS ticket (gated). */
val wsCredential: String,
/** Session cookie for REST API auth in gated mode (null in loopback). */
val sessionCookie: String? = null,
)
/**
@@ -298,12 +296,17 @@ class AuthLoginViewModel(
AuthManager.setHost(state.host)
AuthManager.setPort(port)
AuthManager.setToken(result.wsCredential)
if (result.sessionCookie != null) {
AuthManager.setSessionCookie(result.sessionCookie)
AuthManager.setWsAuthParam("ticket")
} else {
if (state.authMode == DashboardAuthMode.TOKEN_ONLY) {
// Loopback mode — no session cookie; ensure any stale one
// is cleared so the jar only sends the Bearer token.
AuthManager.setSessionCookie(null)
AuthManager.setWsAuthParam("token")
} else {
// Gated (BASIC_AUTH / ALL): the session cookie was captured
// automatically by the shared CookieJar during the login
// call (issue #470), so we keep it and switch the WS auth
// param to the ticket minted above.
AuthManager.setWsAuthParam("ticket")
}
ApiClient.rebuild()
HermesWsClient.connect()
@@ -419,27 +422,10 @@ class AuthLoginViewModel(
return null
}
// Step 2: Extract hermes_session_at cookie from Set-Cookie headers
val setCookieHeaders = loginResp.headers("Set-Cookie")
var sessionAccessToken: String? = null
for (header in setCookieHeaders) {
if (header.startsWith("hermes_session_at=")) {
sessionAccessToken = header.split(";")[0].removePrefix("hermes_session_at=").trim()
break
}
}
if (sessionAccessToken.isNullOrBlank()) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Login succeeded but no session cookie received",
)
}
return null
}
val cookieHeader = "hermes_session_at=$sessionAccessToken"
// The session cookie is captured automatically by the shared
// CookieJar (issue #470) attached to OkHttpProvider.probe — no
// manual Set-Cookie parsing needed. We still mint a WS ticket below
// using whatever cookie the jar carries into that request.
// Step 3: Mint a WebSocket ticket using the session cookie
val ticketClient =
@@ -453,7 +439,6 @@ class AuthLoginViewModel(
Request
.Builder()
.url("$baseUrl/api/auth/ws-ticket")
.header("Cookie", cookieHeader)
.post("{}".toRequestBody())
.build()
val ticketResp = ticketClient.newCall(ticketReq).execute()
@@ -482,7 +467,7 @@ class AuthLoginViewModel(
return null
}
return ConnectResult(wsCredential = ticket, sessionCookie = sessionAccessToken)
return ConnectResult(wsCredential = ticket)
} catch (e: Exception) {
_uiState.update {
it.copy(