SHA256
fix(android): retain HTTPS sessions with upstream default cookie flags
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:
1 parent
129eac675c
commit
4b483c20e3
16 files changed
+220
-22
No files matched your search
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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`
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+7
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in new issue
Block a user