8.4 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/login, /register, /forgot-password when
TURNSTILE_SECRET_KEY is set. No-op when unset (dev mode).
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.