diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index c3d915e..78d98bc 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index ce3bfdb..c59de5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/SECURITY.md b/SECURITY.md index 4dc7f05..70597fc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 810e86d..ce2d792 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 { diff --git a/android/app/src/androidTestNativeIntegration/java/community/yuvomi/mobile/NativeUpstreamTest.kt b/android/app/src/androidTestNativeIntegration/java/community/yuvomi/mobile/NativeUpstreamTest.kt index a12d727..6a0e4d1 100644 --- a/android/app/src/androidTestNativeIntegration/java/community/yuvomi/mobile/NativeUpstreamTest.kt +++ b/android/app/src/androidTestNativeIntegration/java/community/yuvomi/mobile/NativeUpstreamTest.kt @@ -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) diff --git a/android/app/src/main/java/community/yuvomi/mobile/NativeApi.kt b/android/app/src/main/java/community/yuvomi/mobile/NativeApi.kt index 0aaa485..1e5f3d2 100644 --- a/android/app/src/main/java/community/yuvomi/mobile/NativeApi.kt +++ b/android/app/src/main/java/community/yuvomi/mobile/NativeApi.kt @@ -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() @@ -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() diff --git a/android/app/src/main/java/community/yuvomi/mobile/NativeController.kt b/android/app/src/main/java/community/yuvomi/mobile/NativeController.kt index 9b56907..afcb15d 100644 --- a/android/app/src/main/java/community/yuvomi/mobile/NativeController.kt +++ b/android/app/src/main/java/community/yuvomi/mobile/NativeController.kt @@ -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.") } } diff --git a/android/app/src/test/java/community/yuvomi/mobile/NativeCookiesTest.kt b/android/app/src/test/java/community/yuvomi/mobile/NativeCookiesTest.kt index d8b78cc..307390d 100644 --- a/android/app/src/test/java/community/yuvomi/mobile/NativeCookiesTest.kt +++ b/android/app/src/test/java/community/yuvomi/mobile/NativeCookiesTest.kt @@ -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) diff --git a/docs/architecture.md b/docs/architecture.md index 71d606a..c9de8b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/build-android.md b/docs/build-android.md index 020907a..b0bf908 100644 --- a/docs/build-android.md +++ b/docs/build-android.md @@ -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 diff --git a/docs/continuation.md b/docs/continuation.md index 12069a5..538b163 100644 --- a/docs/continuation.md +++ b/docs/continuation.md @@ -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 diff --git a/docs/login-troubleshooting.md b/docs/login-troubleshooting.md new file mode 100644 index 0000000..061b923 --- /dev/null +++ b/docs/login-troubleshooting.md @@ -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. diff --git a/docs/native-delivery-report.md b/docs/native-delivery-report.md index 61f8b32..48a8040 100644 --- a/docs/native-delivery-report.md +++ b/docs/native-delivery-report.md @@ -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 diff --git a/docs/validation.md b/docs/validation.md index 0eb625b..5bfa961 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -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 | | --- | --- | diff --git a/scripts/integration-server.mjs b/scripts/integration-server.mjs index 8b621b5..aeef900 100644 --- a/scripts/integration-server.mjs +++ b/scripts/integration-server.mjs @@ -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'; diff --git a/scripts/test-integration.py b/scripts/test-integration.py index 114e7c7..b3da39d 100644 --- a/scripts/test-integration.py +++ b/scripts/test-integration.py @@ -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)