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

+2 -2
View File
@@ -10,8 +10,8 @@ android {
applicationId = "community.yuvomi.mobile"
minSdk = 26
targetSdk = 37
versionCode = 2
versionName = "0.2.0-native-preview"
versionCode = 3
versionName = "0.2.1-native-preview"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
@@ -77,9 +77,16 @@ class NativeUpstreamTest {
compose.onNodeWithTag("connect").performClick()
awaitTag("username")
compose.onNodeWithTag("username").performTextInput("mobiletest")
compose.onNodeWithTag("password").performTextInput(password + "-invalid")
compose.onNodeWithTag("login").performScrollTo().performClick()
awaitTag("auth-error")
compose.onNodeWithText("Benutzername oder Passwort wurden vom Server nicht akzeptiert.").assertIsDisplayed()
compose.onNodeWithTag("password").performTextInput(password)
compose.onNodeWithTag("login").performScrollTo().performClick()
awaitTag("native-dashboard")
compose.activityRule.scenario.onActivity {
assertEquals(null, ViewModelProvider(it)[NativeController::class.java].state.error)
}
captureFixtureDashboard()
compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
@@ -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.")
}
}
@@ -10,14 +10,47 @@ import org.junit.Test
class NativeCookiesTest {
private val server = ServerAddress.parse("https://home.example:8443")
@Test fun acceptsOnlySecureSameHostSessionCookies() {
@Test fun acceptsOnlyAllowListedSameHostCookies() {
val jar = NativeCookies(server)
jar.accept(listOf("yuvomi.sid=valid; Secure; HttpOnly; Path=/; Max-Age=60"))
assertEquals("yuvomi.sid=valid", jar.header())
jar.accept(listOf("yuvomi.sid=insecure; Path=/", "other=unwanted; Secure", "yuvomi.sid=other-host; Domain=evil.example; Secure"))
jar.accept(listOf("other=unwanted; Secure", "yuvomi.sid=other-host; Domain=evil.example; Secure"))
assertEquals("yuvomi.sid=valid", jar.header())
}
@Test fun acceptsUpstreamDefaultCookieOnHttpsWithoutServerSecureAttribute() {
val jar = NativeCookies(server)
jar.accept(listOf("yuvomi.sid=default-cookie; HttpOnly; Path=/; Max-Age=60"))
assertEquals("yuvomi.sid=default-cookie", jar.header())
assertTrue(jar.hasSession())
val restored = NativeCookies(server, jar.serialized())
assertEquals("yuvomi.sid=default-cookie", restored.header())
assertTrue(restored.hasSession())
jar.accept(listOf("yuvomi.sid=; Path=/; Max-Age=0"))
assertFalse(jar.hasSession())
}
@Test fun emptyExpiredAndMissingCookiesAreNotAuthenticatedSessions() {
val jar = NativeCookies(server)
assertFalse(jar.hasSession())
jar.accept(listOf("yuvomi.sid=; Path=/; Max-Age=60"))
assertFalse(jar.hasSession())
val now = System.currentTimeMillis()
jar.accept(listOf("yuvomi.sid=short; Path=/; Max-Age=1"), now)
assertFalse(jar.hasSession(now + 2_000))
}
@Test fun authenticationErrorsDistinguishCredentialsCodeAndSessionHandoff() {
assertEquals(
"Benutzername oder Passwort wurden vom Server nicht akzeptiert.",
NativeApiError(401, authenticationStep = "/auth/login").userMessage(),
)
assertTrue(NativeApiError(401, authenticationStep = "/auth/2fa/verify").userMessage().contains("Bestätigungscode"))
assertTrue(NativeApiError(401, "session_cookie_missing").userMessage().contains("Sitzungscookie"))
assertTrue(NativeApiError(401, "session_not_accepted").userMessage().contains("akzeptiert die Sitzung nicht"))
assertEquals("Anmeldung oder Sitzung ist nicht mehr gültig.", NativeApiError(401).userMessage())
}
@Test fun absoluteExpiryDoesNotExtendWhenRestoring() {
val now = System.currentTimeMillis()
val jar = NativeCookies(server)