fix(android): retain HTTPS sessions with upstream default cookie flags
Android validation / emulator (37.0) (push) Failing after 3s
Android validation / emulator (35) (push) Failing after 9s
iOS validation / simulator (push) Successful in 1m48s
Android validation / build (push) Successful in 5m10s

Reproduce browser-success/native-401 with unmodified upstream SESSION_SECURE=false behind HTTPS. Keep cookies in the existing HTTPS-only origin-bound encrypted jar regardless of the server Secure attribute; preserve TLS, redirect, expiry and origin protections. Distinguish credential/2FA rejection from missing/rejected session handoff and clear stale login errors on retry. Verify 13 JVM tests plus the 7-test real-server suite in both Secure/default-cookie modes, including bad-password then successful native login. Bump Android preview to 0.2.1/code 3; extend CI and document the defect without claiming access to the user's production server.
This commit is contained in:
Yuvomi Mobile Contributors committed 2026-10-21 09:53:55 +02:00
1 parent 129eac675c
commit 4b483c20e3
16 files changed
+220 -22

No files matched your search

@@ -9,7 +9,9 @@ import org.json.JSONObject
data class SessionCookie(val name: String, val value: String, val expires: Long)
/** An explicit same-origin jar; never installs a process-wide CookieHandler. */
/** Origin-bound HTTPS-only jar; never installs a process-wide CookieHandler.
* The native transport enforces HTTPS even when the server omits cookie Secure.
*/
class NativeCookies(private val server: ServerAddress, serialized: String? = null) {
private val values = linkedMapOf<String, SessionCookie>()
@@ -35,7 +37,10 @@ class NativeCookies(private val server: ServerAddress, serialized: String? = nul
for (header in headers) {
val parsed = runCatching { HttpCookie.parse(header) }.getOrDefault(emptyList())
for (cookie in parsed) {
if (!allowed(cookie.name) || !cookie.secure || !safe(cookie.value)) continue
// Upstream defaults SESSION_SECURE to false, even behind an HTTPS
// proxy. Never downgrade transport: all of this jar's cookies are
// bound to ServerAddress's HTTPS origin regardless of this flag.
if (!allowed(cookie.name) || !safe(cookie.value)) continue
if (cookie.domain != null && !cookie.domain.trimStart('.').equals(host, true)) continue
if (cookie.path != null && cookie.path != "/") continue
if (cookie.maxAge == 0L) {
@@ -61,12 +66,27 @@ class NativeCookies(private val server: ServerAddress, serialized: String? = nul
fun clear() = values.clear()
fun hasSession(now: Long = System.currentTimeMillis()): Boolean = values["yuvomi.sid"]?.let {
it.value.isNotEmpty() && it.expires > now
} == true
private fun allowed(name: String) = name == "yuvomi.sid" || name == "csrf-token"
private fun safe(value: String) = value.length <= 8192 && value.none { it <= ' ' || it == ';' || it == ',' || it == '\u007f' }
}
class NativeApiError(val status: Int, val reason: String = "") : Exception() {
fun userMessage(): String = when (status) {
class NativeApiError(val status: Int, val reason: String = "", val authenticationStep: String = "") : Exception() {
fun authenticationMessage(): String? = when {
status == 401 && reason == "session_cookie_missing" ->
"Der Server hat die Anmeldung bestätigt, aber kein nutzbares Sitzungscookie geliefert. Bitte Server und Reverse Proxy prüfen."
status == 401 && reason == "session_not_accepted" ->
"Die Anmeldung wurde bestätigt, aber der Server akzeptiert die Sitzung nicht. Bitte Server und Reverse Proxy prüfen."
status == 401 && authenticationStep == "/auth/login" -> "Benutzername oder Passwort wurden vom Server nicht akzeptiert."
status == 401 && authenticationStep == "/auth/2fa/verify" ->
"Der Bestätigungscode ist ungültig oder die ausstehende Anmeldung ist abgelaufen. Bitte erneut anmelden."
else -> null
}
fun userMessage(): String = authenticationMessage() ?: when (status) {
401 -> "Anmeldung oder Sitzung ist nicht mehr gültig."
403 -> "Diese Aktion ist nicht erlaubt. Bitte prüfe deine Berechtigungen."
409 -> "Der Eintrag wurde inzwischen geändert. Bitte neu laden."
@@ -85,6 +105,10 @@ class NativeApi(val server: ServerAddress, private val cookies: NativeCookies, p
cookies.clear()
}
fun requireSessionCookie() {
if (!cookies.hasSession()) throw NativeApiError(401, "session_cookie_missing")
}
fun request(path: String, method: String = "GET", body: JSONObject? = null, retry: Boolean = true): JSONObject {
require(path.startsWith("/") && !path.startsWith("//") && !path.contains('#') && !path.contains('\\'))
val target = URI(server.origin + "/api/v1" + path)
@@ -136,7 +160,10 @@ class NativeApi(val server: ServerAddress, private val cookies: NativeCookies, p
request("/auth/me", retry = false)
return request(path, method, body, retry = false)
}
if (status !in 200..299) throw NativeApiError(status, data.optString("reason"))
if (status !in 200..299) {
val step = path.takeIf { it == "/auth/login" || it == "/auth/2fa/verify" }.orEmpty()
throw NativeApiError(status, data.optString("reason"), step)
}
return data
} finally {
connection.disconnect()
@@ -66,7 +66,7 @@ class NativeController(application: Application) : AndroidViewModel(application)
val selectedGeneration = generation
state = previous.copy(busy = true, error = null)
worker.execute {
val result = runCatching { block(previous) }
val result = runCatching { block(previous.copy(error = null)) }
main.post {
if (generation != selectedGeneration) return@post
state = result.fold(
@@ -165,6 +165,7 @@ class NativeController(application: Application) : AndroidViewModel(application)
}
private fun authenticated(previous: NativeState, response: JSONObject): NativeState {
requireNotNull(client).requireSessionCookie()
val user = response.getJSONObject("user")
val permissions = response.getJSONObject("permissions")
val signedIn = previous.copy(
@@ -179,7 +180,7 @@ class NativeController(application: Application) : AndroidViewModel(application)
return try {
signedIn.copy(dashboard = requireNotNull(client).request("/dashboard"))
} catch (error: Exception) {
if (error is NativeApiError && error.status == 401) throw error
if (error is NativeApiError && error.status == 401) throw NativeApiError(401, "session_not_accepted")
signedIn.copy(error = "Die Anmeldung war erfolgreich. Die Übersicht konnte noch nicht geladen werden.")
}
}