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

+1
View File
@@ -100,6 +100,7 @@ jobs:
bash android/gradlew -p android connectedDebugAndroidTest bash android/gradlew -p android connectedDebugAndroidTest
python3 scripts/verify-test-results.py android/app/build/outputs/androidTest-results/connected/debug --minimum 6 python3 scripts/verify-test-results.py android/app/build/outputs/androidTest-results/connected/debug --minimum 6
python3 scripts/test-integration.py "$YUVOMI_UPSTREAM" --android python3 scripts/test-integration.py "$YUVOMI_UPSTREAM" --android
python3 scripts/test-integration.py "$YUVOMI_UPSTREAM" --android --cookie-without-secure
- uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de - uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de
if: always() if: always()
with: with:
+14
View File
@@ -2,6 +2,18 @@
## [Unreleased] ## [Unreleased]
### Android 0.2.1 native preview (code 3)
- Fix login against upstream-default session cookies without `Secure`: Android
now retains them in its origin-bound HTTPS-only jar instead of losing the
authenticated session. HTTP, cross-origin redirects and invalid TLS remain denied.
- Distinguish credential/code rejection from a missing/rejected native session;
clear a previous login error after a successful retry.
- Exercise both upstream cookie configurations over real HTTPS, including a wrong
password followed by a successful native login. See `docs/login-troubleshooting.md`.
### Initial native preview
- Begin the genuine native rebuild: Compose/SwiftUI screens, direct HTTPS API, - Begin the genuine native rebuild: Compose/SwiftUI screens, direct HTTPS API,
protected native session storage and initial auth/dashboard/task/note/shopping protected native session storage and initial auth/dashboard/task/note/shopping
flows. Archive the rejected WebView renderer and its tests. Full PWA parity is flows. Archive the rejected WebView renderer and its tests. Full PWA parity is
@@ -10,6 +22,8 @@
- Gitea workflows for the maintainer's Ubuntu pool and native M4 Pro runner, - Gitea workflows for the maintainer's Ubuntu pool and native M4 Pro runner,
explicit SDK/KVM prerequisites, SHA-256 checkout and unsigned-only CI release builds. explicit SDK/KVM prerequisites, SHA-256 checkout and unsigned-only CI release builds.
### Archived browser prototype (not shipped)
- Native Android HTTPS onboarding, origin-bound browsing, session persistence, - Native Android HTTPS onboarding, origin-bound browsing, session persistence,
recovery, server erasure, camera/document selection, downloads and blob exports. recovery, server erasure, camera/document selection, downloads and blob exports.
- SwiftUI/WKWebView iOS implementation, isolated web sign-in, native settings, - SwiftUI/WKWebView iOS implementation, isolated web sign-in, native settings,
+5 -2
View File
@@ -16,8 +16,11 @@ server vulnerabilities belong in its private reporting channel.
- Passwords and second-factor codes exist transiently for the API request; they - Passwords and second-factor codes exist transiently for the API request; they
are not saved to preferences, keychains, logs, artifacts or application state are not saved to preferences, keychains, logs, artifacts or application state
restoration. Server cookies, not passwords, persist the session. restoration. Server cookies, not passwords, persist the session.
- Android persists allow-listed Secure cookies with their absolute expiry in - Android treats every allow-listed native cookie as HTTPS-only, bound to the
AES-GCM ciphertext backed by AndroidKeyStore, with the origin authenticated as configured origin/port. This also applies when upstream omits the `Secure`
attribute (`SESSION_SECURE=false` is upstream's default); no HTTP transport is
enabled. Persistent cookies retain their absolute expiry in AES-GCM ciphertext
backed by AndroidKeyStore, with the origin authenticated as
additional data. iOS uses a private ephemeral URLSession jar and origin-scoped additional data. iOS uses a private ephemeral URLSession jar and origin-scoped
Keychain items protected with WhenUnlockedThisDeviceOnly. Keychain items protected with WhenUnlockedThisDeviceOnly.
- CSRF tokens follow the upstream API protocol. Only an explicit `csrf_invalid` - CSRF tokens follow the upstream API protocol. Only an explicit `csrf_invalid`
+2 -2
View File
@@ -10,8 +10,8 @@ android {
applicationId = "community.yuvomi.mobile" applicationId = "community.yuvomi.mobile"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 2 versionCode = 3
versionName = "0.2.0-native-preview" versionName = "0.2.1-native-preview"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
@@ -77,9 +77,16 @@ class NativeUpstreamTest {
compose.onNodeWithTag("connect").performClick() compose.onNodeWithTag("connect").performClick()
awaitTag("username") awaitTag("username")
compose.onNodeWithTag("username").performTextInput("mobiletest") 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("password").performTextInput(password)
compose.onNodeWithTag("login").performScrollTo().performClick() compose.onNodeWithTag("login").performScrollTo().performClick()
awaitTag("native-dashboard") awaitTag("native-dashboard")
compose.activityRule.scenario.onActivity {
assertEquals(null, ViewModelProvider(it)[NativeController::class.java].state.error)
}
captureFixtureDashboard() captureFixtureDashboard()
compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED) compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) 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) 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) { class NativeCookies(private val server: ServerAddress, serialized: String? = null) {
private val values = linkedMapOf<String, SessionCookie>() private val values = linkedMapOf<String, SessionCookie>()
@@ -35,7 +37,10 @@ class NativeCookies(private val server: ServerAddress, serialized: String? = nul
for (header in headers) { for (header in headers) {
val parsed = runCatching { HttpCookie.parse(header) }.getOrDefault(emptyList()) val parsed = runCatching { HttpCookie.parse(header) }.getOrDefault(emptyList())
for (cookie in parsed) { 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.domain != null && !cookie.domain.trimStart('.').equals(host, true)) continue
if (cookie.path != null && cookie.path != "/") continue if (cookie.path != null && cookie.path != "/") continue
if (cookie.maxAge == 0L) { if (cookie.maxAge == 0L) {
@@ -61,12 +66,27 @@ class NativeCookies(private val server: ServerAddress, serialized: String? = nul
fun clear() = values.clear() 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 allowed(name: String) = name == "yuvomi.sid" || name == "csrf-token"
private fun safe(value: String) = value.length <= 8192 && value.none { it <= ' ' || it == ';' || it == ',' || it == '\u007f' } private fun safe(value: String) = value.length <= 8192 && value.none { it <= ' ' || it == ';' || it == ',' || it == '\u007f' }
} }
class NativeApiError(val status: Int, val reason: String = "") : Exception() { class NativeApiError(val status: Int, val reason: String = "", val authenticationStep: String = "") : Exception() {
fun userMessage(): String = when (status) { 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." 401 -> "Anmeldung oder Sitzung ist nicht mehr gültig."
403 -> "Diese Aktion ist nicht erlaubt. Bitte prüfe deine Berechtigungen." 403 -> "Diese Aktion ist nicht erlaubt. Bitte prüfe deine Berechtigungen."
409 -> "Der Eintrag wurde inzwischen geändert. Bitte neu laden." 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() 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 { fun request(path: String, method: String = "GET", body: JSONObject? = null, retry: Boolean = true): JSONObject {
require(path.startsWith("/") && !path.startsWith("//") && !path.contains('#') && !path.contains('\\')) require(path.startsWith("/") && !path.startsWith("//") && !path.contains('#') && !path.contains('\\'))
val target = URI(server.origin + "/api/v1" + path) 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) request("/auth/me", retry = false)
return request(path, method, body, 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 return data
} finally { } finally {
connection.disconnect() connection.disconnect()
@@ -66,7 +66,7 @@ class NativeController(application: Application) : AndroidViewModel(application)
val selectedGeneration = generation val selectedGeneration = generation
state = previous.copy(busy = true, error = null) state = previous.copy(busy = true, error = null)
worker.execute { worker.execute {
val result = runCatching { block(previous) } val result = runCatching { block(previous.copy(error = null)) }
main.post { main.post {
if (generation != selectedGeneration) return@post if (generation != selectedGeneration) return@post
state = result.fold( state = result.fold(
@@ -165,6 +165,7 @@ class NativeController(application: Application) : AndroidViewModel(application)
} }
private fun authenticated(previous: NativeState, response: JSONObject): NativeState { private fun authenticated(previous: NativeState, response: JSONObject): NativeState {
requireNotNull(client).requireSessionCookie()
val user = response.getJSONObject("user") val user = response.getJSONObject("user")
val permissions = response.getJSONObject("permissions") val permissions = response.getJSONObject("permissions")
val signedIn = previous.copy( val signedIn = previous.copy(
@@ -179,7 +180,7 @@ class NativeController(application: Application) : AndroidViewModel(application)
return try { return try {
signedIn.copy(dashboard = requireNotNull(client).request("/dashboard")) signedIn.copy(dashboard = requireNotNull(client).request("/dashboard"))
} catch (error: Exception) { } 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.") signedIn.copy(error = "Die Anmeldung war erfolgreich. Die Übersicht konnte noch nicht geladen werden.")
} }
} }
@@ -10,14 +10,47 @@ import org.junit.Test
class NativeCookiesTest { class NativeCookiesTest {
private val server = ServerAddress.parse("https://home.example:8443") private val server = ServerAddress.parse("https://home.example:8443")
@Test fun acceptsOnlySecureSameHostSessionCookies() { @Test fun acceptsOnlyAllowListedSameHostCookies() {
val jar = NativeCookies(server) val jar = NativeCookies(server)
jar.accept(listOf("yuvomi.sid=valid; Secure; HttpOnly; Path=/; Max-Age=60")) jar.accept(listOf("yuvomi.sid=valid; Secure; HttpOnly; Path=/; Max-Age=60"))
assertEquals("yuvomi.sid=valid", jar.header()) 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()) 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() { @Test fun absoluteExpiryDoesNotExtendWhenRestoring() {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val jar = NativeCookies(server) val jar = NativeCookies(server)
+6 -1
View File
@@ -8,7 +8,7 @@ window around that website. Both shipping entry points now render native views:
| Screens and navigation | Jetpack Compose / Material 3 primitives, Yuvomi tokens | SwiftUI, Yuvomi tokens | | Screens and navigation | Jetpack Compose / Material 3 primitives, Yuvomi tokens | SwiftUI, Yuvomi tokens |
| Application state | AndroidViewModel + Compose state | MainActor ObservableObject | | Application state | AndroidViewModel + Compose state | MainActor ObservableObject |
| JSON API | HttpsURLConnection, serialized worker | URLSession actor, bounded streaming delegate | | JSON API | HttpsURLConnection, serialized worker | URLSession actor, bounded streaming delegate |
| Session ownership | Explicit origin-bound secure-cookie jar | Private ephemeral URLSession cookie storage | | Session ownership | Explicit origin-bound HTTPS-only cookie jar | Private ephemeral URLSession cookie storage |
| Persistent cookies | AES-GCM, AndroidKeyStore key, origin as authenticated data | Keychain, WhenUnlockedThisDeviceOnly, origin-scoped account | | Persistent cookies | AES-GCM, AndroidKeyStore key, origin as authenticated data | Keychain, WhenUnlockedThisDeviceOnly, origin-scoped account |
| Server logic | Existing unmodified Yuvomi API | Existing unmodified Yuvomi API | | Server logic | Existing unmodified Yuvomi API | Existing unmodified Yuvomi API |
@@ -34,6 +34,11 @@ navigation destinations. The other modules remain unfinished.
Session-only cookies are not converted to long-lived cookies. Session-only cookies are not converted to long-lived cookies.
- Native UI mutations wait for the server result. Permissions remain enforced - Native UI mutations wait for the server result. Permissions remain enforced
server-side; sparse module/widget permissions also control native affordances. server-side; sparse module/widget permissions also control native affordances.
- Android's native jar enforces HTTPS itself rather than rejecting a valid server
cookie solely because its `Secure` attribute is absent. This supports upstream's
default session settings behind HTTPS proxies without permitting HTTP or
cross-origin cookie replay. Browser cookie configuration remains the server
operator's responsibility; `SESSION_SECURE=true` is recommended for HTTPS sites.
- Local removal clears the native cookie vault and in-memory records. It is not - Local removal clears the native cookie vault and in-memory records. It is not
remote revocation. Logout attempts server revocation and still erases the local remote revocation. Logout attempts server revocation and still erases the local
session if the server cannot be reached. session if the server cannot be reached.
+6
View File
@@ -62,6 +62,7 @@ git clone --filter=blob:none https://github.com/ulsklyc/yuvomi.git /path/to/upst
git -C /path/to/upstream checkout 10bfef7686ed7303b4c74c7fcc55a5eb16356c9d git -C /path/to/upstream checkout 10bfef7686ed7303b4c74c7fcc55a5eb16356c9d
npm ci --omit=dev --prefix /path/to/upstream npm ci --omit=dev --prefix /path/to/upstream
python3 scripts/test-integration.py /path/to/upstream --android python3 scripts/test-integration.py /path/to/upstream --android
python3 scripts/test-integration.py /path/to/upstream --android --cookie-without-secure
``` ```
Requires Node 22.14+ and a running emulator. The harness creates and removes its Requires Node 22.14+ and a running emulator. The harness creates and removes its
@@ -71,6 +72,11 @@ Keep test reports private if you customize the harness with real data (not advis
The native test calls the real API through native controls and saves a synthetic The native test calls the real API through native controls and saves a synthetic
dashboard screenshot under `app/build/outputs/native-screenshots/`. It does not dashboard screenshot under `app/build/outputs/native-screenshots/`. It does not
demonstrate native SSO merely because the protocol fixture also tests OIDC. demonstrate native SSO merely because the protocol fixture also tests OIDC.
Both commands retain HTTPS and system/test-CA validation; the second reproduces
upstream's default `SESSION_SECURE=false` cookie attributes behind an HTTPS proxy.
The native jar must retain that session while continuing to forbid HTTP and
cross-origin replay. The scenario also rejects a wrong password, then retries
successfully without retaining the old error.
The test writes through `PlatformTestStorageRegistry`; AGP collects the file in The test writes through `PlatformTestStorageRegistry`; AGP collects the file in
`outputs/connected_android_test_additional_output/integrationAndroidTest/` before `outputs/connected_android_test_additional_output/integrationAndroidTest/` before
uninstalling the fixture. The harness rejects missing/ambiguous output and removes uninstalling the fixture. The harness rejects missing/ambiguous output and removes
+13 -1
View File
@@ -13,6 +13,18 @@ Read [native parity](native-parity.md), [architecture](architecture.md), and
shopping slices exist, but **no module has accepted full visual/functional parity**. shopping slices exist, but **no module has accepted full visual/functional parity**.
The remaining pages/settings/auth/file/offline/notification work is explicit. The remaining pages/settings/auth/file/offline/notification work is explicit.
### Android login follow-up: 0.2.1 native preview / code 3
A user reported immediate HTTP-401 login failure although the same direct password
login works in the browser. Reproduced against unmodified upstream by setting its
existing `SESSION_SECURE=false` default behind the HTTPS fixture: the old Android
jar discarded the cookie and the next authenticated request failed. The fix keeps
all native cookies HTTPS/origin-bound independent of that server attribute;
certificate checks, redirect refusal, encryption and expiry are unchanged. Login
errors now distinguish credential/code rejection from session-cookie problems and
a successful retry clears the old error. Both secure/default-cookie test modes
are in CI; see `login-troubleshooting.md` and the latest validation section.
The owner-authorized signing key and four Gitea secrets were created before this The owner-authorized signing key and four Gitea secrets were created before this
correction. Their private backup is `/home/neste/yuvomi-signing/initial-20261021`. correction. Their private backup is `/home/neste/yuvomi-signing/initial-20261021`.
Do not print its contents. Uncommitted signing changes are preserved in Git stash Do not print its contents. Uncommitted signing changes are preserved in Git stash
@@ -34,7 +46,7 @@ is not typechecked by Linux. Gitea's Mac job now bootstraps pinned Node before
checkout, but actual Xcode results still need inspection. Ubuntu emulator jobs checkout, but actual Xcode results still need inspection. Ubuntu emulator jobs
continue requiring accessible KVM; do not weaken or fake that evidence. continue requiring accessible KVM; do not weaken or fake that evidence.
Latest local native evidence: 10 JVM tests, 6 API-35 Compose tests, 7 real-server Initial 0.2.0 local native evidence: 10 JVM tests, 6 API-35 Compose tests, 7 real-server
instrumentation tests (including those same 6), both lint variants and native instrumentation tests (including those same 6), both lint variants and native
debug/unsigned release APK/AAB builds pass. Sixteen portable Swift tests and strict debug/unsigned release APK/AAB builds pass. Sixteen portable Swift tests and strict
format/parsing pass. Screenshot export now uses AndroidX test storage/AGP output format/parsing pass. Screenshot export now uses AndroidX test storage/AGP output
+51
View File
@@ -0,0 +1,51 @@
# Android native login: 0.2.1 preview fix
## Reproduced compatibility defect
The Android 0.2.0 native preview could show “Anmeldung oder Sitzung ist nicht mehr
gültig” even when the identical direct username/password login worked in the PWA.
Upstream defaults to `SESSION_SECURE=false`; an HTTPS reverse proxy does not by
itself change this cookie attribute. The old native jar rejected the session
cookie without `Secure`, so the successful login was followed by an unauthenticated
API request. This was an app compatibility defect, not evidence of a bad password.
Android 0.2.1 native preview (version code 3) keeps the cookie in its existing
HTTPS-only, origin/port-bound jar. It still never makes HTTP requests, follows
redirects or shares cookies with another server. System TLS validation,
AndroidKeyStore encryption and absolute expiry remain unchanged. The native client
does not depend on weakening the server or changing its configuration. For browser
security, server operators should still use `SESSION_SECURE=true` on HTTPS sites.
## Error distinctions
- Rejected username/password: the server rejected `/auth/login` with HTTP 401.
- Invalid/expired second-factor step: the verification endpoint rejected the code
or its pending session. Restart login if necessary.
- Login confirmed but no usable cookie: inspect server/reverse-proxy cookie
configuration; no password-reset claim is made.
- Login confirmed but session rejected: inspect server/proxy session handling.
The app never displays raw cookie values, credentials or arbitrary server response
bodies as diagnostics. A failed attempt's message is cleared after successful retry.
If the updated app still fails, report the exact new message, app/server version,
whether the direct browser password login works and whether 2FA is enabled.
**Do not send passwords, session cookies, recovery codes or authentication tokens.**
## Reproduction and regression evidence
```sh
python3 scripts/test-integration.py /path/to/pinned-upstream --android --cookie-without-secure
python3 scripts/test-integration.py /path/to/pinned-upstream --android
```
The first mode fails with the old native jar despite successful protocol-level
browser login. After the fix, it passes native login, a wrong-password/correct-retry
sequence, dashboard/recreation, task/note/shopping operations and logout. The normal
Secure-cookie mode remains required too. These use synthetic temporary records and
the unmodified pinned upstream, not the user's server or credentials.
Development APKs from different CI jobs can have different debug signing keys.
If Android rejects an update as signature-incompatible, uninstall the old preview
before installing the new one. This clears local app/session data and requires a
fresh login; server household records are not deleted. Production signing remains
paused and full native PWA parity is not implied by this fix.
+5
View File
@@ -4,6 +4,11 @@
This report closes the initial native rebuild slice, not the requested complete This report closes the initial native rebuild slice, not the requested complete
1:1 replacement. No module has accepted functional and visual parity. 1:1 replacement. No module has accepted functional and visual parity.
**Follow-up:** Android 0.2.1 native preview (code 3) fixes a reproduced session-cookie
login compatibility defect. See [login troubleshooting](login-troubleshooting.md)
and the newer [validation evidence](validation.md); the original 0.2.0 results
below remain historical evidence, not a replacement for the fixed APK.
## 1. Outcome ## 1. Outcome
The rejected embedded-browser application has been replaced by genuine native The rejected embedded-browser application has been replaced by genuine native
+27 -1
View File
@@ -8,7 +8,33 @@ tests are in `androidTestNative` / `androidTestNativeIntegration`, JVM native po
tests, and Swift production contracts/transport tests. Archived WebView tests are tests, and Swift production contracts/transport tests. Archived WebView tests are
not included in the native suites or counted toward native completion. not included in the native suites or counted toward native completion.
## Executed locally — 2026-10-21 ## Android 0.2.1 / code 3: reproduced login regression
The user reported immediate login failure despite a working direct browser login.
The root cause was reproduced using upstream's `SESSION_SECURE=false` default over
HTTPS: browser/protocol login succeeded, but the old Android jar discarded the
session cookie and its next authenticated API request failed. The fixed jar binds
all cookies to HTTPS itself instead of requiring this optional server attribute.
HTTP, cross-origin requests/redirects, invalid certificates, plaintext persistence
and session-only cookie persistence remain disallowed.
- Before-fix failure retained in `/home/neste/yuvomi-login-cookie-before.log` and
`/home/neste/yuvomi-login-cookie-before.xml` (synthetic fixture only).
- After-fix upstream-default cookie mode: 7 instrumentation tests passed,
zero failures/skips; complete harness exited successfully.
- Secure-cookie mode: the same 7 tests passed, zero failures/skips.
- Both modes test a rejected password followed by successful retry with no stale
error, native dashboard/recreation, task/note/shopping calls and logout.
- 13 JVM tests passed, including default-cookie persistence/deletion, expired/empty
session detection and separate credential/2FA/session-handoff error messages.
- Logs: `/home/neste/yuvomi-login-cookie-after.log`,
`/home/neste/yuvomi-login-secure-after.log`, `/home/neste/yuvomi-login-unit.log`.
See [login troubleshooting](login-troubleshooting.md). This reproduces a matching
compatibility defect without accessing the user's server or credentials; it does
not claim inspection of that particular server's configuration.
## Initial 0.2.0 preview evidence — 2026-10-21
| Check | Observed result | | Check | Observed result |
| --- | --- | | --- | --- |
+4 -1
View File
@@ -13,7 +13,10 @@ process.env.PORT = '0';
process.env.BIND_ADDRESS = '127.0.0.1'; process.env.BIND_ADDRESS = '127.0.0.1';
process.env.DB_PATH = path.join(directory, 'fixture.db'); process.env.DB_PATH = path.join(directory, 'fixture.db');
process.env.SESSION_SECRET = randomBytes(48).toString('hex'); process.env.SESSION_SECRET = randomBytes(48).toString('hex');
process.env.SESSION_SECURE = 'true'; // Exercise both explicit Secure cookies and upstream's default behind HTTPS.
const sessionSecure = process.env.YUVOMI_TEST_SESSION_SECURE ?? 'true';
if (!['true', 'false'].includes(sessionSecure)) throw new Error('Invalid fixture cookie mode');
process.env.SESSION_SECURE = sessionSecure;
process.env.DB_ENCRYPTION_KEY = ''; process.env.DB_ENCRYPTION_KEY = '';
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
process.env.LOG_LEVEL = 'warn'; process.env.LOG_LEVEL = 'warn';
+9 -5
View File
@@ -41,7 +41,7 @@ def collect_native_screenshot(additional, screenshots):
(screenshots / "native-dashboard.png").write_bytes(capture) (screenshots / "native-dashboard.png").write_bytes(capture)
def run(upstream, android): def run(upstream, android, cookie_without_secure=False):
revision = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip() revision = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip()
assert revision == REVISION, f"Expected pinned upstream {REVISION}, got {revision}" assert revision == REVISION, f"Expected pinned upstream {REVISION}, got {revision}"
base_temp = Path("/tmp/opencode") if Path("/tmp/opencode").exists() else Path(tempfile.gettempdir()) base_temp = Path("/tmp/opencode") if Path("/tmp/opencode").exists() else Path(tempfile.gettempdir())
@@ -56,7 +56,8 @@ def run(upstream, android):
with (directory / "server.log").open("w") as log: with (directory / "server.log").open("w") as log:
process = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary], process = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary],
cwd=upstream, stdout=log, stderr=log, cwd=upstream, stdout=log, stderr=log,
env={**os.environ, "NODE_EXTRA_CA_CERTS": str(directory / "cert.pem")}) env={**os.environ, "NODE_EXTRA_CA_CERTS": str(directory / "cert.pem"),
"YUVOMI_TEST_SESSION_SECURE": "false" if cookie_without_secure else "true"})
try: try:
for _ in range(200): for _ in range(200):
if (directory / "ready.json").exists(): if (directory / "ready.json").exists():
@@ -102,7 +103,7 @@ def run(upstream, android):
status, body, headers = request("/api/v1/auth/login", {"username": "mobiletest", "password": password}) status, body, headers = request("/api/v1/auth/login", {"username": "mobiletest", "password": password})
assert status == 200 assert status == 200
session = [cookie for cookie in cookies if cookie.name == "yuvomi.sid"][0] session = [cookie for cookie in cookies if cookie.name == "yuvomi.sid"][0]
assert session.secure and session.has_nonstandard_attr("HttpOnly") assert session.secure == (not cookie_without_secure) and session.has_nonstandard_attr("HttpOnly")
assert "SameSite=Lax" in " ".join(headers.get_all("Set-Cookie")) assert "SameSite=Lax" in " ".join(headers.get_all("Set-Cookie"))
csrf = json.loads(body)["csrfToken"] csrf = json.loads(body)["csrfToken"]
assert request("/api/v1/auth/me")[0] == 200 assert request("/api/v1/auth/me")[0] == 200
@@ -110,7 +111,8 @@ def run(upstream, android):
assert request("/api/v1/auth/logout", {})[0] == 403 assert request("/api/v1/auth/logout", {})[0] == 403
assert request("/api/v1/auth/logout", {}, {"X-CSRF-Token": csrf})[0] == 200 assert request("/api/v1/auth/logout", {}, {"X-CSRF-Token": csrf})[0] == 200
assert request("/api/v1/auth/me")[0] == 401 assert request("/api/v1/auth/me")[0] == 401
print("PASS: login, Secure/HttpOnly/SameSite cookies, persistence, CSRF rejection and logout/revocation") cookie_mode = "HTTPS with upstream-default non-Secure session cookie" if cookie_without_secure else "Secure session cookie"
print(f"PASS: login, {cookie_mode}, HttpOnly/SameSite, persistence, CSRF rejection and logout/revocation")
assert request("/api/v1/auth/oidc/start")[0] == 200 assert request("/api/v1/auth/oidc/start")[0] == 200
status, body, _ = request("/api/v1/auth/me") status, body, _ = request("/api/v1/auth/me")
identity = json.loads(body) identity = json.loads(body)
@@ -168,5 +170,7 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("upstream", type=Path) parser.add_argument("upstream", type=Path)
parser.add_argument("--android", action="store_true") parser.add_argument("--android", action="store_true")
parser.add_argument("--cookie-without-secure", action="store_true",
help="Test upstream's default session-cookie attributes over HTTPS; never enables HTTP")
arguments = parser.parse_args() arguments = parser.parse_args()
run(arguments.upstream.resolve(), arguments.android) run(arguments.upstream.resolve(), arguments.android, arguments.cookie_without_secure)