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 <noreply@anthropic.com>
9.2 KiB
Authentication & security
Password hashing
- Primary: argon2id, memory cost 19 MiB, time cost 2, parallelism 1 (OWASP 2023 recommended profile).
- Fallback: bcryptjs (12 rounds) for legacy rows.
- Transparent migration: on successful login against a bcrypt hash, the password is rehashed as argon2id and the row updated. Users migrate without any action.
- The
argon2package is loaded optionally — if not installed, registration and password changes fall back to bcrypt without breaking.
Token transport
Hybrid, chosen at request time by src/utils/platform.js based on User-Agent
and optional X-Client header:
| Client | Transport | Storage | JWT lifetime |
|---|---|---|---|
| Web browser | ped_auth httpOnly + sameSite=lax cookie |
— (no client storage) | 30 d (sliding 24 h idle enforced server-side) |
| Capacitor app | Authorization: Bearer <jwt> |
iOS Keychain / Android EncryptedSharedPreferences | 365 d (no idle check) |
authMiddleware reads Bearer first, falls back to cookie. An empty Bearer
string falls through to cookie parsing — fixes clients that always emit the
header.
Session table
user_sessions is the authoritative source. Each row holds token_hash
(SHA-256 of the JWT), user_id, ip_address, device_label, last_activity.
Middleware on every authenticated request:
- Verify JWT signature and expiry.
- Look up
token_hashinuser_sessions. If missing and the user has any other sessions → 401 "Session revoked". No sessions at all → fail open (pre-migration users). - Compute idle (
NOW() - last_activity).- Web (
!isMobileClient): if idle > 24 h → delete the session row, clear cookie, return 401 withidleTimeout: true. - Mobile: skip idle check.
- Web (
- On POST / PUT / DELETE / PATCH only, if idle > 10 min (throttle), update
last_activity = NOW()and re-set the cookie with a fresh 30-day maxAge (cookie slides with activity). GET / HEAD do NOT extend the session — prevents polling from defeating the idle policy.
Idle-timeout kicks write an audit_log entry with
action='session_idle_timeout' and the minute count, plus a console.warn
for Loki.
Two-factor authentication
TOTP via speakeasy, 30-second step, verification window ±1 step.
Backup codes
- Generated automatically on first 2FA enable (10 codes, 10 characters,
XXXXX-XXXXXformat, excluded-characters alphabet: no0/O/1/I). - Stored as bcrypt hashes in
users.totp_backup_codes(JSON array). - Consumed atomically on login via
SELECT … FOR UPDATEtransaction — race between parallel attempts serializes correctly, a code can only succeed once. POST /api/auth/2fa/backup-codesregenerates the full set (requires current password).GET /api/auth/2fa/backup-codes/countreturns remaining count.- Consumed codes are also logged in
audit_log(2fa_backup_code_used). - Cleared when 2FA is disabled.
OIDC (Authorization Code + PKCE)
- Implemented with
openid-client. - State + PKCE verifier + nonce are bundled into an HMAC-signed token
(signed with
JWT_SECRET) — stateless, survives restarts and scales horizontally. 5-minute TTL. - SSRF guard: issuer URL must use
https://and not resolve to any private / loopback / link-local IP. Blocks attacks like issuer set tohttp://169.254.169.254/(AWS metadata). - First-time link: requires
email_verified: trueclaim from the IdP. Missing or false → 401 witherror=email_unverified. Prevents an unverified-email SSO account from taking over an existing local account. - Already-linked users with a DIFFERENT
oidc_subare refused (error=sub_mismatch). - Auto-create on first SSO: new user row,
email_verified=true, password column holds a random 32-byte hex string (not a hash).canLocalAuth=falsehides password/2FA/sessions UI for these users. Server-side endpoints (/change-password,/setup-2fa) also reject with an SSO-aware message.
Providers tested: Authentik, Azure AD, Okta, Keycloak, Google, PocketID.
Logout and cross-tab sync
POST /api/auth/logoutdeletes the current session row and clears the cookie.- Frontend broadcasts
{type:'logout'}onBroadcastChannel('pedscribe-auth'); sibling tabs drop UI and reload. authFetch.jsinstalls a globalfetchinterceptor; any 401 on an authenticated/api/*request triggers the same logout path.- Service-worker caches are cleared on every logout (
caches.keys()→caches.delete).
Rate limits
| Endpoint | Limit |
|---|---|
/api/* general |
200 req / min / IP |
/api/auth/login |
10 / 15 min |
/api/auth/register |
5 / hour |
/api/auth/forgot-password |
5 / hour |
/api/auth/resend-verification |
3 / 15 min |
/api/auth/change-password, /setup-2fa, /verify-2fa, /disable-2fa |
20 / 15 min |
Limits are per-IP (express-rate-limit). A clinic behind a single NAT shares
the bucket; increase or switch to per-user keying if that becomes a problem.
Login enumeration resistance
/api/auth/login returns "Invalid credentials" for:
- unknown email (runs a bcrypt compare against a fixed dummy hash to equalize timing)
- wrong password
- disabled account
"Email not verified" is still returned for unverified accounts — deemed a
necessary UX tradeoff over perfect indistinguishability.
Turnstile (Cloudflare bot protection)
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
DATA_ENCRYPTION_KEY env var (64 hex chars = 32 bytes; any other string is
SHA-256-derived with a warning). In production mode the server refuses to
start without it.
| Data | Encryption |
|---|---|
Nextcloud access tokens (users.nextcloud_token) |
AES-256-GCM via encryptString; legacy plaintext rows are detected and re-encrypted on next use |
Audio backups (audio_backups.audio_data) |
Gzipped, then AES-256-GCM with a 0x01 version byte prefix; legacy rows (no prefix) pass through unchanged |
| PHI in audit details | Redacted via src/utils/redact.js (SSN, phone, email, DoB regex patterns; 500-char cap; note-body heuristic truncation) before insert |
HTTP security headers
Helmet defaults plus:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload- Content-Security-Policy:
script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com challenges.cloudflare.com(do not addunsafe-evalunless a reviewed dependency requires it)script-src-attr 'none'(blocks inline event handlers)frame-src 'self' challenges.cloudflare.comobject-src 'none'
X-Content-Type-Options: nosniff- Response bodies on 5xx use generic
'Request failed'; full error stays server-side inlogger.error/ Loki.
File uploads
src/routes/documents.js accepts document uploads after:
- Extension / MIME check.
- Magic-byte sniff via
src/utils/fileType.js— refuses mismatches (e.g., a.jpgwith a PHP payload).
CORS
- Production (
NODE_ENV=productionorAPP_URLset): refuses to start if neitherAPP_URLnorCORS_ORIGINSis configured. - Origin whitelist = union of
APP_URLand comma-separatedCORS_ORIGINS. - Requests with no Origin header always pass (mobile, curl, server-to-server).
credentials: trueso the cookie travels on cross-origin web requests from permitted origins.
Roles
| Role | Access |
|---|---|
admin |
Everything. First registered user auto-promoted. |
moderator |
Learning Hub CMS + standard user features. |
user |
Clinical features, no admin routes. |
Audit logging
Every auth-adjacent event is written to audit_log via a batched writer
(src/utils/auditQueue.js) — 1-second flush interval or 50-entry batch.
Drained on SIGTERM before pool close. Sent to Loki in parallel (fire-and-forget).
Common action values: register, login, login_failed, login_blocked,
login_oidc, logout, email_verified, password_changed,
password_reset, 2fa_enabled, 2fa_backup_code_used,
2fa_backup_codes_regenerated, oidc_linked, session_idle_timeout.
Maintenance
scripts/maintenance.js:
npm run maint:check— reports collation drift, row counts, index listnpm run maint:reindex—REINDEX DATABASE+ALTER DATABASE … REFRESH COLLATION VERSION+ANALYZE
Run after any Postgres image upgrade. The startup drift check runs this
automatically when pg_database.datcollversion diverges from the library's
actual version.