pediatric-ai-scribe-v3/docs/authentication.md
Daniel fa2e7523d6
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Has been cancelled
docs: My Resources, sign-in codes, invitations, and what the image carries
Nothing documented My Resources, the slide renderer, PubMed or web search, and
the authentication doc predated both sign-in codes and registration invitations.

docs/my-resources.md is new and covers the feature end to end: what a resource
is, where its material comes from, why both searches run in the route rather
than as tools the model never called, why keyword engines get the topic while
retrieval gets the instruction too, how a presentation is designed as a deck
rather than written as markdown, the separate multi-image path, and what the
export pipeline is made of.

docs/authentication.md gains sign-in codes — storage, lifetime, reuse,
supersession, guessing, and that two-factor still applies — and registration
invitations, including the exact condition that decides when a code may be
deleted and why it is written to match the status the list displays. Both new
rate limits are in the table, with a note that Express matches app.use paths on
segment boundaries, so a new sign-in endpoint needs its own limiter or it has
none at all.

docs/deployment.md now says what the runtime image carries and why — pandoc for
Word, python3 with apk-installed lxml and pillow for the slide renderer,
python-pptx pinned, and that PDF conversion is not in the image at all but goes
to Gotenberg, so Word and PowerPoint still work when it is down.

docs/configuration.md picks up LOGIN_RATE_LIMIT_MAX, LOGIN_CODE_RATE_LIMIT_MAX
and GOTENBERG_URL, none of which were listed. README gains a My Resources
section and indexes the two new docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 20:49:10 +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.

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.