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 @@ -
-
+
-
+