pediatric-ai-scribe-v3/docs/authentication.md
Daniel f7e0277552
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m4s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
docs: accounts in the README, and the sign-in code path is verified end to end
The README's feature list never mentioned accounts at all. It now says how
somebody signs in — password or an emailed code, side by side — that passwords
are argon2id with bcrypt rows rehashed on next sign-in, and that registration can
be open, closed or invite-only with codes that are revocable while live and
deletable only once spent.

The browser-STT note now says why the Web Speech gate exists rather than only
that it exists: Chrome and Edge send that audio to Google, so it is off unless a
user turns it on.

And the one thing that could not be demonstrated before now can. With SMTP
configured, a real send returns true, and driving the sign-in screen in a browser
— request a code, type it — returns 200 from /login-code/verify and enters the
app. docs/authentication.md records that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 22:02:25 +02:00

13 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 argon2 package 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:

  1. Verify JWT signature and expiry.
  2. Look up token_hash in user_sessions. If missing and the user has any other sessions → 401 "Session revoked". No sessions at all → fail open (pre-migration users).
  3. Compute idle (NOW() - last_activity).
    • Web (!isMobileClient): if idle > 24 h → delete the session row, clear cookie, return 401 with idleTimeout: true.
    • Mobile: skip idle check.
  4. 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-XXXXX format, excluded-characters alphabet: no 0/O/1/I).
  • Stored as bcrypt hashes in users.totp_backup_codes (JSON array).
  • Consumed atomically on login via SELECT … FOR UPDATE transaction — race between parallel attempts serializes correctly, a code can only succeed once.
  • POST /api/auth/2fa/backup-codes regenerates the full set (requires current password). GET /api/auth/2fa/backup-codes/count returns 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 to http://169.254.169.254/ (AWS metadata).
  • First-time link: requires email_verified: true claim from the IdP. Missing or false → 401 with error=email_unverified. Prevents an unverified-email SSO account from taking over an existing local account.
  • Already-linked users with a DIFFERENT oidc_sub are 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=false hides 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/logout deletes the current session row and clears the cookie.
  • Frontend broadcasts {type:'logout'} on BroadcastChannel('pedscribe-auth'); sibling tabs drop UI and reload.
  • authFetch.js installs a global fetch interceptor; 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/login-code/request 5 / hour
/api/auth/login-code/verify 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.

Requesting a sign-in code is limited more tightly than attempting one, because each request sends mail to somebody else's address — the cost of abuse lands on the mailbox owner, not the caller. LOGIN_CODE_RATE_LIMIT_MAX overrides it.

These are separate limiters rather than covered by the /api/auth/login one: Express matches app.use paths on segment boundaries, so /api/auth/login does not match /api/auth/login-code/.... A new sign-in endpoint needs its own entry or it has no limit at all.

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.

Sign-in codes

A six-digit code emailed to the address being signed in with, offered beside the password rather than instead of it. The screen asks for the email first, then shows both routes: the code depends on mail being delivered and the password does not, so neither is allowed to be the only way in.

POST /api/auth/login-code/requestPOST /api/auth/login-code/verify.

What makes it a front door rather than a weaker side entrance:

Storage bcrypt hash only, in login_codes — a code read out of the database is not a working credential
Lifetime 10 minutes
Reuse single use, marked used before the session is issued so a replay cannot race it
Supersession requesting a new code deletes the previous one
Guessing 5 wrong attempts burn the code; six digits is a million possibilities, which is plenty against a person and nothing against a script with unlimited tries at one code
Two-factor still applies — a code proves you can read the mailbox, which is one factor, and an account that asked for a second still wants it

Generation uses rejection sampling on crypto.randomBytes, not modulo, which would make low digits slightly likelier.

loginCodes.sweep() clears codes more than a day past expiry. It is fire and forget: housekeeping never fails a request.

Verified end to end against the running server with SMTP configured: a code is requested from the sign-in screen, the mail is sent, and typing the code returns 200 from /login-code/verify and enters the app.

Frontend note

public/js/authFetch.js keeps an allowlist of /api paths callable with no verified account owner, and rejects everything else before it is sent. A new pre-auth endpoint must be added there or it fails as a "Connection error" with no request ever leaving the browser.

Registration invitations

registration_invites holds codes that let someone register while registration_invite_only is on. Only the hash is stored; the code is shown once, at creation.

Four states: active, used, expired, revoked.

Revoke stops a live code and leaves the row, marked. Delete removes the row, and is only permitted once the code can no longer be redeemed:

(used_at IS NOT NULL OR (revoked_at IS NULL AND expires_at <= NOW()))

Deleting a code that could still be redeemed takes it off the list without taking it out of anybody's inbox: the holder keeps something that looks valid, it quietly stops working, and nothing is left to say who had it. Revoked rows are kept because revoking records a decision somebody took.

The condition is written to match the status the admin list displays. The simpler used OR expires_at <= NOW() would also catch a revoked code whose date had since passed — a row the screen still labels revoked and offers no delete on, so button and query would disagree about the same row.

DELETE /api/admin/invites/spent clears them in bulk under the same rule. It is declared before /invites/:id or Express reads spent as an id.

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 add unsafe-eval unless a reviewed dependency requires it)
    • script-src-attr 'none' (blocks inline event handlers)
    • frame-src 'self' challenges.cloudflare.com
    • object-src 'none'
  • X-Content-Type-Options: nosniff
  • Response bodies on 5xx use generic 'Request failed'; full error stays server-side in logger.error / Loki.

File uploads

src/routes/documents.js accepts document uploads after:

  1. Extension / MIME check.
  2. Magic-byte sniff via src/utils/fileType.js — refuses mismatches (e.g., a .jpg with a PHP payload).

CORS

  • Production (NODE_ENV=production or APP_URL set): refuses to start if neither APP_URL nor CORS_ORIGINS is configured.
  • Origin whitelist = union of APP_URL and comma-separated CORS_ORIGINS.
  • Requests with no Origin header always pass (mobile, curl, server-to-server).
  • credentials: true so 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 list
  • npm run maint:reindexREINDEX 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.