# Authentication & security ## Sign-in is SSO-only The front door is `sso.pedshub.com` (Authentik, `/home/danvics/docker/authentik-pedshub`). `oidc.enabled` and `oidc.disable_local_auth` are both `true`, so `/api/auth/login`, `/register`, `/forgot-password`, `/reset-password`, `/change-password` and the 2FA routes answer 403 (`requireLocalAuth`). The OIDC client is `src/routes/oidc.js`: signed state cookie, PKCE, nonce, `email_verified` required before an existing local account is linked, `sub` mismatch refused, session row written before the cookie is set. New accounts are created at the SSO from an invitation link (`invite.py` there); an SSO account whose email matches a local one signs into that account. Roles follow the SSO's groups on every sign-in when `oidc.admin_groups` / `oidc.moderator_groups` name them (production: `pedshub-admins`, `pedshub-moderators`; anyone in neither is a user); the last admin is never demoted by a claim. The sections below describe the local machinery that remains behind the switch. ## Signed in at PedsHub means signed in here A visitor with no session here is not shown the sign-in page straight away. The page first asks the provider silently (`/api/auth/oidc?silent=1`, which adds `prompt=none`): someone already signed in at sso.pedshub.com — from the quiz app, say, following a deck link — arrives signed in without a click, the way a Kerberos ticket carries across services. Someone not signed in there gets the provider's refusal, which the callback turns into the ordinary sign-in page (`?sso=none`, no message). The attempt happens once per browser session, never after an explicit sign-out and never inside the mobile shell, and the URL fragment (a share link, a tab) is kept across the round trip. ## Signing out signs you out of PedsHub Sign out ends this app's session and then the PedsHub (Authentik) session: the server answers the sign-out with the provider's end-session address (`end_session_endpoint` from discovery, with `post_logout_redirect_uri` back to this app and the client id), and the browser goes there and returns. So "sign out" means signed out — on a shared ward computer the next person is not one click from the account — and because the two PedsHub apps share the one provider session, signing out of either signs you out of both. The app's landing page must be in the provider's redirect list for the return to work; without it Authentik shows its own "you've logged out" page, which still ends the session. The mobile shell only signs out locally. ## Lockdown: the admin panel as view-only `ADMIN_LOCKDOWN=true` in the environment (never a setting, so no admin can switch it off) makes every setting that changes how the service behaves read-only: prompts, model policy, the Clinical Assistant's models and limits, speech and transcription, SMTP and mail templates, the SSO provider, and any key added later that nobody has explicitly listed as day-to-day. The server refuses the write with 403 (`src/utils/adminLockdown.js`; the gate at the top of `src/routes/adminConfig.js`, and `PUT /api/auth/oidc/config` checks it itself). The panel greys the fields and says why, but the refusal is the control. Still editable under lockdown: the announcement banner, `registration_enabled`, `feature.*` switches, `site.*`, and the test buttons. Lifting it takes a host change and a restart. ## 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. ## Sign-in codes and invitations Both live at the SSO (`sso.pedshub.com`, Authentik) rather than in this app: sign-in is email → code, and new accounts come from an invitation link minted with `authentik-pedshub/invite.py`. The app's own sign-in codes and registration invites were removed once sign-in became SSO-only. ## 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` | 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 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.