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
|
||||
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 --cookie-without-secure
|
||||
- uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de
|
||||
if: always()
|
||||
with:
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
## [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,
|
||||
protected native session storage and initial auth/dashboard/task/note/shopping
|
||||
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,
|
||||
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,
|
||||
recovery, server erasure, camera/document selection, downloads and blob exports.
|
||||
- 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
|
||||
are not saved to preferences, keychains, logs, artifacts or application state
|
||||
restoration. Server cookies, not passwords, persist the session.
|
||||
- Android persists allow-listed Secure cookies with their absolute expiry in
|
||||
AES-GCM ciphertext backed by AndroidKeyStore, with the origin authenticated as
|
||||
- Android treats every allow-listed native cookie as HTTPS-only, bound to the
|
||||
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
|
||||
Keychain items protected with WhenUnlockedThisDeviceOnly.
|
||||
- CSRF tokens follow the upstream API protocol. Only an explicit `csrf_invalid`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+7
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
| Application state | AndroidViewModel + Compose state | MainActor ObservableObject |
|
||||
| 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 |
|
||||
| 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.
|
||||
- Native UI mutations wait for the server result. Permissions remain enforced
|
||||
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
|
||||
remote revocation. Logout attempts server revocation and still erases the local
|
||||
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
|
||||
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 --cookie-without-secure
|
||||
```
|
||||
|
||||
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
|
||||
dashboard screenshot under `app/build/outputs/native-screenshots/`. It does not
|
||||
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
|
||||
`outputs/connected_android_test_additional_output/integrationAndroidTest/` before
|
||||
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**.
|
||||
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
|
||||
correction. Their private backup is `/home/neste/yuvomi-signing/initial-20261021`.
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
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 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -13,7 +13,10 @@ process.env.PORT = '0';
|
||||
process.env.BIND_ADDRESS = '127.0.0.1';
|
||||
process.env.DB_PATH = path.join(directory, 'fixture.db');
|
||||
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.NODE_ENV = 'test';
|
||||
process.env.LOG_LEVEL = 'warn';
|
||||
|
||||
@@ -41,7 +41,7 @@ def collect_native_screenshot(additional, screenshots):
|
||||
(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()
|
||||
assert revision == REVISION, f"Expected pinned upstream {REVISION}, got {revision}"
|
||||
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:
|
||||
process = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary],
|
||||
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:
|
||||
for _ in range(200):
|
||||
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})
|
||||
assert status == 200
|
||||
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"))
|
||||
csrf = json.loads(body)["csrfToken"]
|
||||
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", {}, {"X-CSRF-Token": csrf})[0] == 200
|
||||
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
|
||||
status, body, _ = request("/api/v1/auth/me")
|
||||
identity = json.loads(body)
|
||||
@@ -168,5 +170,7 @@ if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("upstream", type=Path)
|
||||
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()
|
||||
run(arguments.upstream.resolve(), arguments.android)
|
||||
run(arguments.upstream.resolve(), arguments.android, arguments.cookie_without_secure)
|
||||
Reference in new issue
Block a user