# 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 ` | 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/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` (`unsafe-eval` is required by @xenova/transformers for in-browser Whisper) - `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: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.