feat(mobile): biometric sign-in (Face ID / Touch ID / fingerprint)

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
This commit is contained in:
Daniel 2026-04-28 02:27:48 +02:00
parent 503f5afaad
commit 8f49c4dcb9
7 changed files with 179 additions and 0 deletions

View file

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

View file

@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Biometric login (capacitor-native-biometric). USE_BIOMETRIC is the
API 28+ permission; older devices ignore it. No legacy FINGERPRINT
entry needed because capacitor-native-biometric targets API 23+. -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application
android:allowBackup="false"
android:fullBackupContent="false"

View file

@ -6,6 +6,8 @@
<string>en</string>
<key>CFBundleDisplayName</key>
<string>PedScribe</string>
<key>NSFaceIDUsageDescription</key>
<string>PedScribe uses Face ID to securely sign you in without re-entering your password.</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>

View file

@ -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"
}
}

View file

@ -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;}

View file

@ -43,6 +43,16 @@
<!-- Login Form -->
<form id="login-form" class="auth-form" autocomplete="off">
<h2>Sign In</h2>
<!-- Biometric quick-login (Capacitor only). Hidden by default;
revealed by auth.js when isNativeApp() + biometric available
+ credentials previously enrolled. -->
<button type="button" id="btn-bio-login" class="btn-auth btn-bio-login hidden" style="display:none;">
<i class="fas fa-fingerprint"></i> <span id="bio-login-label">Sign in with biometric</span>
</button>
<div id="bio-divider" class="hidden" style="display:none;text-align:center;margin:6px 0 12px;position:relative;">
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or use password</span>
<hr style="border:none;border-top:1px solid #e5e7eb;position:absolute;top:50%;left:0;right:0;margin:0;">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" id="login-email" required placeholder="your@email.com">

View file

@ -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');