From 82ed46d01baaad0abd0cfb2463d89648f4a2a2bf Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 30 Jul 2026 17:22:03 +0200 Subject: [PATCH] Fix Turnstile in the mobile app; drop it from login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Turnstile challenge failed reliably inside the Capacitor WebView, which blocked login and registration from the Android app. Three separate causes: 1. Android WebView blocks third-party cookies by default. Turnstile runs in a cross-origin iframe from challenges.cloudflare.com and needs its own storage, so the widget never emitted a token. MainActivity now calls setAcceptThirdPartyCookies on the app's own WebView. 2. The register handler read the Turnstile response with an unscoped document.querySelector, which matched the *login* widget's input (it comes first in the DOM). Registration therefore submitted the login widget's token — single-use with a 5 minute expiry, so any prior login attempt or slow signup made it fail server-side. 3. The register and forgot-password widgets auto-rendered inside forms that start at display:none, where Turnstile does not reliably complete a challenge, and nothing re-rendered them when the form was shown. Widgets are now rendered explicitly when their form first becomes visible, and tokens are captured from the render callback instead of being read back out of the injected input — which makes the unscoped lookup in (2) structurally impossible. Added error/expired/timeout callbacks so a widget failure surfaces the Cloudflare error code instead of failing silently behind a generic toast. Login is no longer gated at all. It is the path mobile users hit constantly, and it is already covered by a 10-per-15-min per-IP rate limit, a constant-time credential check, and TOTP 2FA. Registration and password reset — the endpoints that actually attract bots — stay gated. Co-Authored-By: Claude Opus 5 --- docs/api-reference.md | 3 +- docs/authentication.md | 16 ++- e2e/tests/session-persistence.spec.js | 17 +-- .../java/com/pedshub/scribe/MainActivity.java | 24 ++++ public/index.html | 11 +- public/js/auth.js | 106 +++++++++++++----- src/routes/auth.js | 18 ++- 7 files changed, 144 insertions(+), 51 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 63c587f..653e7fe 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -103,8 +103,7 @@ Authenticate a user. Supports optional TOTP two-factor authentication. On succes { "email": "string", "password": "string", - "totpCode": "string (optional, required if 2FA is enabled)", - "turnstileToken": "string" + "totpCode": "string (optional, required if 2FA is enabled)" } ``` - **Response:** diff --git a/docs/authentication.md b/docs/authentication.md index 3d29e43..64283a4 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -123,9 +123,23 @@ necessary UX tradeoff over perfect indistinguishability. ## Turnstile (Cloudflare bot protection) -Applied to `/api/auth/login`, `/register`, `/forgot-password` when +Applied to `/api/auth/register` and `/api/auth/forgot-password` when `TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode). +`/api/auth/login` is deliberately **not** gated: the widget could not +reliably complete a challenge inside the Capacitor WebView, which locked +mobile users out of the app. Login is covered instead by its per-IP rate +limit (10 / 15 min), the constant-time credential check, and TOTP 2FA. + +The two remaining widgets are rendered explicitly (`api.js?render=explicit`) +the first time their form becomes visible — Turnstile does not reliably +complete a challenge inside a `display:none` container, and both forms start +hidden. Tokens are captured from the render callback, not read back out of +the injected `[name="cf-turnstile-response"]` input. + +Note that the site key is currently **hardcoded** in `public/index.html`. +`TURNSTILE_SITE_KEY` exists in OpenBao but is not read by any code. + ## Encryption at rest `src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from diff --git a/e2e/tests/session-persistence.spec.js b/e2e/tests/session-persistence.spec.js index b6e9c24..9fd731c 100644 --- a/e2e/tests/session-persistence.spec.js +++ b/e2e/tests/session-persistence.spec.js @@ -2,14 +2,15 @@ // SESSION PERSISTENCE — full logout → login → still on the same // tab + same sub-pill. // -// The UI's login form is gated by a Cloudflare Turnstile token -// whose site key is hardcoded in index.html, which can't be -// completed in the e2e container (Turnstile rejects the non-prod -// origin). So the test does a programmatic logout (clear the -// ped_auth cookie, same effect server-side as clicking Logout) -// followed by a fresh programmatic login — this exercises the -// same localStorage persistence path a real logout/login would, -// without depending on the bot challenge. +// The test does a programmatic logout (clear the ped_auth cookie, +// same effect server-side as clicking Logout) followed by a fresh +// programmatic login. This exercises the same localStorage +// persistence path a real logout/login would. +// +// (Historically this was a workaround for the Turnstile challenge on +// the login form, which could not be completed in the e2e container. +// Login is no longer gated, but driving it programmatically keeps +// the test focused on persistence rather than form mechanics.) // ============================================================ const { test, expect, E2E_BASE, loginAs } = require('../fixtures'); diff --git a/mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java b/mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java index 4934d10..2f3a725 100644 --- a/mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java +++ b/mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java @@ -15,6 +15,7 @@ import android.print.PrintDocumentAdapter; import android.print.PrintManager; import android.provider.MediaStore; import android.util.Base64; +import android.webkit.CookieManager; import android.webkit.PermissionRequest; import android.webkit.WebChromeClient; import android.webkit.WebViewClient; @@ -47,6 +48,9 @@ public class MainActivity extends BridgeActivity { new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE); } + // Allow the Cloudflare Turnstile iframe to use storage. + setupThirdPartyCookies(); + // Setup WebView mic permission granting setupWebViewPermissions(); @@ -60,6 +64,26 @@ public class MainActivity extends BridgeActivity { setupFileBridge(); } + // ── Third-Party Cookies ──────────────────────────────────── + // + // Android WebView blocks third-party cookies by default (unlike Chrome, + // which still allows them for now). Cloudflare Turnstile runs inside a + // cross-origin iframe from challenges.cloudflare.com and needs its own + // storage to run and persist a challenge — without this the widget + // silently stalls or errors and never emits a token, so registration and + // password reset are impossible from inside the app. + // + // This is scoped to our own WebView, which only ever loads the PedScribe + // origin (see allowNavigation in capacitor.config.json), so it is not a + // general relaxation of the app's cookie policy. + + private void setupThirdPartyCookies() { + WebView webView = this.bridge.getWebView(); + CookieManager cookieManager = CookieManager.getInstance(); + cookieManager.setAcceptCookie(true); + cookieManager.setAcceptThirdPartyCookies(webView, true); + } + // ── WebView Microphone Permission ────────────────────────── private void setupWebViewPermissions() { diff --git a/public/index.html b/public/index.html index 22154df..591fb8a 100644 --- a/public/index.html +++ b/public/index.html @@ -11,7 +11,11 @@ - + + @@ -70,7 +74,6 @@ -
-
+
-
+