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

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