From 8f49c4dcb95d7721312adcff84f002a70bdbee89 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 28 Apr 2026 02:27:48 +0200 Subject: [PATCH] feat(mobile): biometric sign-in (Face ID / Touch ID / fingerprint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds opt-in biometric login to the Capacitor app. Replaces the password step on subsequent sign-ins; the 2FA step (if any) still applies — by design, defense in depth. How it works: - After a successful password sign-in on a Capacitor build, prompt the user to enroll. If they accept, capacitor-native-biometric.setCredentials stores the (email, password) pair in the iOS Keychain / Android Keystore with biometric-protected access. The local flag ped_bio_enabled=1 is set so the next launch knows to probe. - On the login form, if isNativeApp() + bioStored() + bioAvailable.ok, reveal the "Sign in with Face ID / Touch ID / fingerprint" button at the top. Label is set from the actual biometryType returned by the plugin so users see what their device supports. - Tap → verifyIdentity (OS prompt) → getCredentials → fill the email + password fields → fire the existing form submit so all the regular flow runs (turnstile, 2FA prompt, error handling, session storage). - Explicit logout deletes credentials AND clears the local flag, hiding the button on the next visit. Auto-logout (token expiry, network) does NOT come through that path, so biometric persists across silent session resets. Storage choice — password not JWT: - JWTs expire and the storage would constantly need refresh. - Storing the password lets the standard /api/auth/login flow run, which already handles password-rotation (a stale stored password just fails 401 → user falls back to typing the new one → re-enrolls). - The password sits in OS-level secure storage, accessible only after successful biometric verification — same security posture as a password manager autofill. Files: - mobile/package.json: add capacitor-native-biometric@^5.0.0 (Capacitor 6 compat) - mobile/android/app/src/main/AndroidManifest.xml: add USE_BIOMETRIC uses-permission - mobile/ios/App/App/Info.plist: add NSFaceIDUsageDescription string - public/js/auth.js: bioPlugin/bioAvailable/bioStored/bioEnroll/ bioRetrieve/bioForget helpers; window.PedBio surface; reveal-on-load; click handler; post-login enrollment prompt; logout cleanup - public/index.html: hidden #btn-bio-login + #bio-divider above the email field on the login form - public/css/styles.css: themed gradient button + hover lift - mobile/README.md: feature list updated Build steps for Daniel: cd mobile && npm install # picks up capacitor-native-biometric npx cap sync # ports the plugin into android/ + ios/ # then build APK / IPA as usual --- mobile/README.md | 4 + .../android/app/src/main/AndroidManifest.xml | 5 + mobile/ios/App/App/Info.plist | 2 + mobile/package.json | 1 + public/css/styles.css | 14 ++ public/index.html | 10 ++ public/js/auth.js | 143 ++++++++++++++++++ 7 files changed, 179 insertions(+) diff --git a/mobile/README.md b/mobile/README.md index 3e2c19e..9b1462c 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -11,6 +11,10 @@ Native mobile wrapper for Pediatric AI Scribe using Capacitor. Provides backgrou - Deep linking (pedscribe:// and https://app.pedshub.com) - Share intent (receive text/PDFs from other apps) - Push notification support +- **Biometric sign-in** (Face ID / Touch ID / fingerprint) — credentials + stored in iOS Keychain / Android Keystore, gated by OS biometric. + Enrolled on first password sign-in (opt-in prompt). 2FA still applies + on top — biometric replaces the password step only. - App Store and Play Store ready ## Prerequisites diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index e481b57..8368019 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,11 @@ + + + en CFBundleDisplayName PedScribe + NSFaceIDUsageDescription + PedScribe uses Face ID to securely sign you in without re-entering your password. CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/mobile/package.json b/mobile/package.json index de99095..d693e2f 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -23,6 +23,7 @@ "@capacitor/share": "^6.0.0", "@capacitor/splash-screen": "^6.0.0", "@capacitor/status-bar": "^6.0.0", + "capacitor-native-biometric": "^5.0.0", "capacitor-secure-storage-plugin": "^0.10.0" } } diff --git a/public/css/styles.css b/public/css/styles.css index 49bb123..2b38f8c 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1146,3 +1146,17 @@ textarea.full-input{resize:vertical;} .docs-sidebar{flex:0 0 auto;max-height:240px;} .docs-reader{min-height:60vh;} } + +/* Biometric login button on the auth screen — themed to match the SSO + button in restraint, but with a friendly accent so it doesn't look + like a duplicate of the password Sign In button. */ +.btn-bio-login{ + background:linear-gradient(135deg,#0ea5e9,#2563eb); + color:white; + margin-bottom:8px; + display:flex;align-items:center;justify-content:center;gap:8px; + transition:transform .12s ease, box-shadow .15s ease; +} +.btn-bio-login:hover{transform:translateY(-1px);box-shadow:0 4px 12px -3px rgba(37,99,235,.45);} +.btn-bio-login:active{transform:translateY(0);} +.btn-bio-login i{font-size:18px;} diff --git a/public/index.html b/public/index.html index 7ae6315..e96c3c6 100644 --- a/public/index.html +++ b/public/index.html @@ -43,6 +43,16 @@

Sign In

+ + +
diff --git a/public/js/auth.js b/public/js/auth.js index 38e5b91..f697b93 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -39,11 +39,90 @@ document.addEventListener('DOMContentLoaded', function() { }); } + // ── Biometric login (Capacitor only) ──────────────────────────────── + // Uses capacitor-native-biometric to gate stored credentials behind + // Face ID / Touch ID / fingerprint. Server is identity (email); + // server-side credential is the user's password (not the JWT — JWTs + // expire and would force a fallback password login to refresh). + // 2FA still applies on top: biometric replaces the password step but + // a 2FA-enabled account still gets the TOTP prompt afterwards. That + // is the intended defense-in-depth. + var BIO_SERVER = 'pedscribe-bio'; // namespace for the keychain/keystore item + var BIO_ENABLED_KEY = 'ped_bio_enabled'; // localStorage flag — used to decide whether to even probe + function bioPlugin() { + try { + if (!isNativeApp()) return null; + var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.NativeBiometric; + return p || null; + } catch (e) { return null; } + } + function bioAvailable() { + var p = bioPlugin(); + if (!p) return Promise.resolve({ ok: false }); + return p.isAvailable() + .then(function (r) { return { ok: !!(r && r.isAvailable), type: r && r.biometryType }; }) + .catch(function () { return { ok: false }; }); + } + function bioStored() { + // Cheap check first — was biometric ever enrolled? If not, skip the + // verifyIdentity prompt path entirely so we don't rattle the user. + try { return localStorage.getItem(BIO_ENABLED_KEY) === '1'; } catch (e) { return false; } + } + function bioEnroll(email, password) { + var p = bioPlugin(); + if (!p) return Promise.reject(new Error('Biometric plugin unavailable')); + return p.setCredentials({ username: email, password: password, server: BIO_SERVER }) + .then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} }); + } + function bioRetrieve() { + var p = bioPlugin(); + if (!p) return Promise.reject(new Error('Biometric plugin unavailable')); + return p.verifyIdentity({ + reason: 'Sign in to PedScribe', + title: 'PedScribe', + subtitle: 'Use biometric to sign in', + description: 'Confirm your identity to continue.' + }).then(function () { + return p.getCredentials({ server: BIO_SERVER }); + }); + } + function bioForget() { + var p = bioPlugin(); + try { localStorage.removeItem(BIO_ENABLED_KEY); } catch (e) {} + if (!p) return Promise.resolve(); + return p.deleteCredentials({ server: BIO_SERVER }).catch(function () { /* fine if missing */ }); + } + // Expose a small surface so settings/logout/etc can call into it. + window.PedBio = { available: bioAvailable, stored: bioStored, enroll: bioEnroll, retrieve: bioRetrieve, forget: bioForget }; + // Auth module initialized // Make sure forms are visible/hidden correctly on load showLoginForm(); + // Biometric login button: reveal on the login form when the device + // supports it AND the user has previously enrolled. Fire-and-forget; + // any failure (no plugin, locked out, hardware missing) just leaves + // the button hidden. + function maybeRevealBioButton() { + var btn = document.getElementById('btn-bio-login'); + var div = document.getElementById('bio-divider'); + if (!btn || !isNativeApp()) return; + if (!bioStored()) return; + bioAvailable().then(function (s) { + if (!s.ok) return; + // Tweak the label to the actual biometry type when known. + var label = document.getElementById('bio-login-label'); + if (label && s.type) { + var typeMap = { 'FACE_ID': 'Sign in with Face ID', 'TOUCH_ID': 'Sign in with Touch ID', 'FACE_AUTHENTICATION': 'Sign in with face recognition', 'FINGERPRINT': 'Sign in with fingerprint' }; + label.textContent = typeMap[s.type] || 'Sign in with biometric'; + } + btn.classList.remove('hidden'); btn.style.display = ''; + if (div) { div.classList.remove('hidden'); div.style.display = ''; } + }); + } + maybeRevealBioButton(); + // Auth screen is hidden by CSS default — only show it when there is no valid session function showAuthScreen() { if (authScreen) authScreen.style.display = 'flex'; @@ -230,6 +309,17 @@ document.addEventListener('DOMContentLoaded', function() { if (adminTabBtn) adminTabBtn.classList.add('hidden'); var docsTabBtn = document.getElementById('docs-tab-btn'); if (docsTabBtn) docsTabBtn.classList.add('hidden'); + // Explicit logout clears biometric — assume the user is leaving the + // device for someone else. Auto-logout (token expiry, network) does + // NOT come through this path, so biometric persists across silent + // session resets. + if (window.PedBio && typeof window.PedBio.forget === 'function') { + window.PedBio.forget(); + } + var bioBtn = document.getElementById('btn-bio-login'); + if (bioBtn) { bioBtn.classList.add('hidden'); bioBtn.style.display = 'none'; } + var bioDiv = document.getElementById('bio-divider'); + if (bioDiv) { bioDiv.classList.add('hidden'); bioDiv.style.display = 'none'; } showLoginForm(); // Clear fields after browser autofill has had a chance to run setTimeout(function() { @@ -434,6 +524,43 @@ document.addEventListener('DOMContentLoaded', function() { } }); + // ---- BIOMETRIC LOGIN BUTTON ---- + // Reads the email + password from the OS-secured keychain (gated behind + // Face ID / Touch ID / fingerprint) and fills the login form. Submits the + // form so all the existing flow (turnstile, 2FA prompt, error handling, + // session storage) runs unchanged. If biometric verification fails, the + // user just gets a toast and falls through to typing the password. + var bioBtn = document.getElementById('btn-bio-login'); + if (bioBtn) { + bioBtn.addEventListener('click', function () { + bioRetrieve() + .then(function (creds) { + if (!creds || !creds.username || !creds.password) { + showToast('No stored credentials', 'error'); + return; + } + var emailEl = document.getElementById('login-email'); + var pwEl = document.getElementById('login-password'); + if (emailEl) emailEl.value = creds.username; + if (pwEl) pwEl.value = creds.password; + // Trigger the same submit path as the password form so all the + // existing handling (turnstile token, 2FA, session storage, etc.) + // runs unchanged. If turnstile hasn't auto-solved yet the form + // will toast "Please complete the verification" — same as a + // manual login attempt before turnstile resolves. + if (loginForm && typeof loginForm.requestSubmit === 'function') loginForm.requestSubmit(); + else if (loginForm) loginForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); + }) + .catch(function (err) { + // User cancelled or biometric failed (locked out, no enrolled + // biometric, etc). Stay quiet for cancel; toast for hard errors. + var msg = (err && (err.message || err.code)) || ''; + if (/cancel/i.test(msg)) return; + showToast('Biometric sign-in failed', 'error'); + }); + }); + } + // ---- LOGIN FORM SUBMIT ---- if (loginForm) { loginForm.addEventListener('submit', function(e) { @@ -492,6 +619,22 @@ document.addEventListener('DOMContentLoaded', function() { } enterApp(data.user, data.token); showToast('Welcome, ' + data.user.name + '!', 'success'); + // Offer biometric enrollment after the very first successful + // password login on a Capacitor device. Only ask once per + // (device, account) — enrollment flips the BIO_ENABLED_KEY flag. + if (isNativeApp() && !bioStored()) { + bioAvailable().then(function (s) { + if (!s.ok) return; + var typeName = ({ 'FACE_ID': 'Face ID', 'TOUCH_ID': 'Touch ID', 'FACE_AUTHENTICATION': 'face recognition', 'FINGERPRINT': 'fingerprint' })[s.type] || 'biometric'; + if (typeof showConfirm === 'function') { + showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () { + bioEnroll(email, password) + .then(function () { showToast(typeName + ' enabled. Use it next time you sign in.', 'success'); }) + .catch(function () { showToast('Could not enable ' + typeName, 'error'); }); + }); + } + }); + } } else { showToast(data.error || 'Login failed', 'error'); if (window.turnstile) turnstile.reset('#turnstile-login');