The feature was removed; the docs still described it as live. Worst of it was api-reference.md, which documented nine /api/learning endpoints and fourteen /api/admin/learning CMS endpoints — routes that answer 404 — plus POST /api/user/webdav-path, whose column was dropped by migration. Anyone reading them was reading fiction. Checked against the running system rather than assumed: no learning table exists, users.webdav_learning_path is gone, generated_image_links is gone, and no route mounts /api/learning or /api/admin/learning. Two things that look like Learning Hub and are not, so they stay: - learningRetrieval.js is live — My Resources uses it. Its settings keep the learning.* names because renaming them would orphan whatever an administrator has already set. retrieval-tuning.md now says so instead of listing the rows under two different feature names. - the moderator role is still assignable. It gated the CMS and now grants nothing; authentication.md says that rather than implying powers it does not have. moderatorMiddleware has no callers left, which is worth removing on its own. auth-admin-learning.md is now auth-admin.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
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
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/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/request → POST /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_invite_only sits between open and closed registration: people may
register, but only with a code. It is subordinate to registration_enabled — with
registration disabled entirely, nobody can register, code or not.
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 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 |
Standard user features. The role is still assignable but no longer grants anything of its own — it gated the Learning Hub CMS, which has been removed. |
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.