pediatric-ai-scribe-v3/docs/openid-setup.md
Daniel b53aa34248 feat: ED multi-stage UX, extensions polish, docs viewer + application-logic docs
Three concurrent themes from this session:

═══════════════════════════════════════════════════════════════════
ED ENCOUNTERS — per-stage cards + consolidate→MDM finalize
═══════════════════════════════════════════════════════════════════

UX redesign per Daniel's feedback ("every stage note should be shown,
if AI is told to modify that particular note then the modified version
is used in final mdm"):

- Each generated stage stays on screen as its own editable card with
  its own embedded "Don't Miss" panel. No more single rolling note
  element that gets replaced on each generation.
- gatherCurrentNotes() reads contenteditable text from each stage card
  before any operation (advance, finalize, persist) so inline edits
  flow into the next AI call and the final consolidate.
- Stage badge is now state-accurate. "Stage N (recording)" with yellow
  background after Add-more before generation; "Stage N" with gray
  after generation. Fixes the bug where the badge flipped to Stage 2
  the moment Add-more was clicked.
- Save & Done now runs TWO server-side AI calls in /finalize:
  1. edConsolidate (new prompt) → polished single final note that
     integrates every stage chronologically (HPI / ROS / PE / ED Course /
     A&P with disposition).
  2. edFinalize (rewritten with full inline 2023 AMA E/M element
     rubric — problems / data / risk definitions, level mapping with
     concrete examples) → MDM JSON.
- Two new cards render after finalize: blue-bordered Final Consolidated
  Note + green-bordered MDM. Stage cards become read-only.
- partial_data on the saved row now stores {stages, finalNote, mdm,
  finalized} so resume re-renders the full state.

Why two-call finalize: a single combined prompt makes the model cut
corners on one task. Two focused calls cost ~2× latency at the very end
of an encounter — acceptable since finalize is a one-time terminal
action, not a per-stage hot path.

Files: public/components/ed-encounter.html, public/js/ed-encounters.js,
src/routes/edEncounters.js, src/utils/prompts.js (edConsolidate added,
edFinalize rewritten).

═══════════════════════════════════════════════════════════════════
EXTENSIONS / PAGERS — visual polish
═══════════════════════════════════════════════════════════════════

Multiple iterations based on Daniel's feedback:

- Layout: align-items:flex-start so action buttons stay pinned top-right
  when long numbers wrap (was align-items:center → buttons drifted into
  the text area, causing visible overlap).
- Number: word-break:break-all + min-width:0 + font-feature-settings:tnum
  so long numbers wrap within their column instead of pushing under the
  buttons. Click-to-copy with a 0.55s green flash + ✓ copied badge.
- Phone/pager Font Awesome icon next to the number in the type color —
  at-a-glance type signal (replacing an earlier 3px left stripe that
  Daniel found visually bulky).
- Name: font-weight 700, font-size 14.5px, color g900, letter-spacing
  -0.012em — scan-target headline typography for long lists.
- Alternating subtle backgrounds by index (white vs #fafbfc) so a long
  list reads as distinct rows.
- Hover: card lifts 1px with a soft shadow; action buttons fade from
  55% to 100% opacity. Cubic-bezier transition on transform.
- Entrance: staggered fade-up animation per card (35ms × index, capped
  at 12). prefers-reduced-motion media query disables motion.
- Empty state: 48px FA icon + heading instead of plain gray text.

Files: public/js/extensions.js, public/css/styles.css.

═══════════════════════════════════════════════════════════════════
DOCS REORGANIZATION + APPLICATION-LOGIC DOCS + ADMIN VIEWER
═══════════════════════════════════════════════════════════════════

Document moves (preserving git history via git mv):
  BROWSER_WHISPER_SETUP.md          → docs/browser-whisper-setup.md
  BROWSER_WHISPER_TROUBLESHOOTING.md → docs/browser-whisper-troubleshooting.md
  DEVELOPER_GUIDE.md                → docs/developer-guide-extended.md
  EMBEDDINGS_SETUP.md               → docs/embeddings-setup.md
  FEATURES_EXPLAINED.md             → docs/features-explained.md
  IMPROVEMENTS.md                   → docs/improvements.md
  OPENID_SETUP.md                   → docs/openid-setup.md
  TRANSCRIPTION_OPTIONS.md          → docs/transcription-options.md
README.md updated with the new paths + a Documentation section that
links to docs/logic/ at the top.

New application-logic doc series (~8,300 lines total) at docs/logic/.
Built with 5 parallel doc-writing agents per Daniel's "use multiple
agents" directive. Each doc explains how a part of the app actually
works — application logic, data flow, design decisions, sacred zones,
how-to-extend recipes — at a depth that lets a new dev (or an AI
assistant) modify the code confidently.

  docs/logic/README.md                — index + recommended reading order
  docs/logic/architecture.md (2166 L) — frontend IIFE pattern, lazy tab
                                         load, backend route convention,
                                         schema, encryption, deployment
  docs/logic/clinical-notes.md (1546L) — every note tab + helper trio
  docs/logic/bedside-and-calculators.md (1373L) — bedside ES module
                                         pocket + calculators + PE Guide
                                         + suture selector
  docs/logic/auth-admin-learning.md (1281L) — auth (local+OIDC+2FA) +
                                         admin panel + Learning Hub
                                         (Quiz engine logic at sub-detail
                                         only — TODO follow-up)
  docs/logic/ai-and-voice.md (1128 L) — callAI 5-provider routing,
                                         prompts, voice/STT, helper trio
  docs/logic/ed-encounters.md (821 L) — multi-stage ED + MDM (this
                                         session's worked example)

Admin-only docs viewer:
- New route /api/admin/docs/{tree,file}: recursively walks docs/, returns
  the tree as JSON; /file?path=X validates path stays inside docs/ and
  renders markdown via marked. Both gated by req.user.role==='admin'.
- New tab "Docs" (book icon) in the sidebar, hidden by default and
  revealed in auth.js when user.role==='admin' (same pattern as the
  existing Admin and CMS tabs).
- New component public/components/admin-docs.html: split-pane layout
  with a tree sidebar + filter input + a markdown reader pane.
- New module public/js/admin-docs.js: lazy-loads the tree on first tab
  activation, renders collapsible folders, persists expanded state and
  last-opened path via UIState. Server-rendered HTML so no client
  markdown parser needed.
- CSS for the viewer (responsive split-pane, code-block styling, table
  scrolling, etc.).
- Mounted at /api/admin/docs (NOT /api) — important: mounting a router
  with router.use(authMiddleware) at /api accidentally 401s every other
  /api/* path (caught and fixed during testing — /api/health was 401'ing).

Files: docs/* (moved + new), README.md, public/components/admin-docs.html
(new), public/js/admin-docs.js (new), src/routes/adminDocs.js (new),
public/index.html (tab + section + script), public/js/auth.js (admin
gate + logout cleanup), public/css/styles.css (viewer styles), server.js
(mount).

═══════════════════════════════════════════════════════════════════
KNOWN GAPS (TODO follow-ups)
═══════════════════════════════════════════════════════════════════

- Learning Hub quiz engine (MCQ / multi-select / T-F scoring + attempt
  tracking + progress dashboard) is covered at the architectural level
  in docs/logic/auth-admin-learning.md but not drilled into the quiz
  data model and scoring flow. Worth a focused follow-up doc.
- ED finalize: if MDM step JSON parse fails, server returns 502 with
  the consolidated finalNote in the error payload, but client doesn't
  surface the partial result. Add a "MDM failed, retry" affordance.
- No e2e Playwright coverage for ED encounters or the new docs viewer.
2026-04-28 03:09:38 +02:00

9.7 KiB

OpenID Connect (OIDC) / PocketID Setup Guide

This guide explains how to configure Single Sign-On (SSO) authentication using OpenID Connect providers like PocketID, Keycloak, Azure AD, Okta, or Google.

Overview

The application supports OIDC authentication alongside traditional email/password login. Once configured, users can:

  • Sign in with their SSO provider (e.g., PocketID)
  • Automatically link existing email accounts to their SSO identity
  • Admins can optionally disable local password login entirely

Prerequisites

  1. An OpenID Connect provider (e.g., PocketID instance)
  2. Admin access to this application
  3. The public URL where your app is deployed (APP_URL in .env)

Configuration Steps

1. Configure Your Identity Provider

First, register this application with your OIDC provider. You'll need:

Redirect URI / Callback URL:

https://your-domain.com/api/auth/oidc/callback

Replace your-domain.com with your actual APP_URL value.

Example: PocketID Setup

  1. Log into your PocketID admin panel
  2. Navigate to ApplicationsAdd Application
  3. Set the callback URL: https://your-domain.com/api/auth/oidc/callback
  4. Copy the Client ID and Client Secret

Example: Keycloak Setup

  1. Create a new client in your Keycloak realm
  2. Set Access Type to confidential
  3. Add Valid Redirect URI: https://your-domain.com/api/auth/oidc/callback
  4. Save and note the Client ID and Client Secret from the Credentials tab

2. Enable OIDC in Application Settings

Log into your application as an admin user, then:

  1. Navigate to Admin PanelSettings (or access /admin-settings.html)
  2. Look for the OpenID Connect (SSO) section
  3. Fill in the following fields:
Field Description Example
Enabled Toggle to enable OIDC true
Issuer URL Your provider's discovery endpoint https://id.example.com or https://keycloak.example.com/realms/myrealm
Client ID Application client ID from your provider pediatric-scribe-client
Client Secret Application client secret (keep confidential) a1b2c3d4...
Button Label Text shown on the SSO login button Sign in with PocketID
Disable Local Auth Hide email/password login (optional) false (keep disabled initially)
Allowed IPs Restrict SSO to specific IP ranges (optional) Leave blank for no restriction
  1. Click Save Settings

3. Test SSO Login

  1. Log out or open an incognito browser window
  2. Visit the login page
  3. You should see a new button: "Sign in with [Your Provider]"
  4. Click it and authenticate with your SSO provider
  5. You'll be redirected back to the application and logged in

Linking Existing Users to SSO

When a user signs in via OIDC for the first time, the system automatically links their account based on email address matching:

Scenario 1: Existing User with Matching Email

If a user already has an account with email doctor@example.com and signs in via SSO with the same email:

  1. The system finds the existing user by email
  2. Links the SSO identity (oidc_sub) to the existing account
  3. The user is logged in
  4. Future logins can use either method (email/password OR SSO)

Database update performed:

UPDATE users
SET oidc_sub = '<provider-unique-id>',
    email_verified = true
WHERE email = 'doctor@example.com';

Scenario 2: New User (No Matching Email)

If the SSO email doesn't match any existing user:

  1. A new account is automatically created
  2. The user is assigned the user role (first user becomes admin)
  3. A random password is generated (not used for SSO logins)
  4. The user is logged in

Scenario 3: Disabled User

If an existing user is disabled (disabled = true in database):

  • SSO login is blocked
  • User sees an error message
  • Admin must re-enable the account from the Admin Panel

Manual Account Linking (CLI)

If you need to manually link an existing user to an SSO identity, use the PostgreSQL database directly:

# Connect to database
docker exec -it pediatric-ai-scribe-postgres psql -U pedscribe -d pedscribe

# Link user by setting their oidc_sub
UPDATE users
SET oidc_sub = 'provider-sub-12345',
    email_verified = true
WHERE email = 'doctor@example.com';

Finding the oidc_sub value:

The oidc_sub is the unique identifier from your OIDC provider (usually a UUID or numeric ID). To find it:

  1. Have the user attempt SSO login once
  2. Check the application logs for their sub claim:
    [OIDC] User logged in: sub=abc-123-def, email=doctor@example.com
    
  3. Use that sub value in the UPDATE statement

Security Considerations

HTTPS Required in Production

OIDC requires HTTPS for security. Ensure your APP_URL uses https://:

APP_URL=https://scribe.example.com

Client Secret Protection

The client secret is stored encrypted in the database. The admin UI masks it after saving (shows ••••••••1234).

Never commit the client secret to Git or share it publicly.

IP Allowlisting (Optional)

To restrict SSO to specific networks (e.g., hospital VPN):

  1. Set Allowed IPs in admin settings to comma-separated CIDR ranges:
    10.0.0.0/8, 192.168.1.0/24
    
  2. Users outside these ranges will see an error when attempting SSO

Disable Local Password Login

Once SSO is working, you can optionally disable traditional email/password login:

  1. In Admin Settings, enable Disable Local Auth
  2. The login page will only show the SSO button
  3. Admins can still use the CLI to reset passwords if needed

Warning: Only disable local auth after confirming all users can access SSO. Keep one admin password as backup.


Troubleshooting

"SSO is not enabled" error

  • Verify Enabled is set to true in admin settings
  • Check application logs for OIDC configuration errors

"Invalid state" or "Expired" error

  • The OIDC flow timed out (5 minute window)
  • Try logging in again
  • If persistent, check server time synchronization

"No email claim" error

Your OIDC provider didn't return an email address. Ensure:

  1. The email scope is requested (default: openid email profile)
  2. Your provider is configured to release email claims
  3. The user's account has an email address set

Email Mismatch

If a user has different emails in the app vs. SSO provider:

Option 1: Update app email to match SSO

UPDATE users SET email = 'new-email@example.com' WHERE id = 123;

Option 2: Update SSO provider email to match app (Provider-specific — consult your IdP documentation)

Callback URL Not Working

Double-check the redirect URI in your OIDC provider settings matches exactly:

https://your-domain.com/api/auth/oidc/callback

Common mistakes:

  • Missing https://
  • Trailing slash (don't include it)
  • Wrong domain (must match APP_URL in .env)

Provider-Specific Examples

PocketID

Issuer URL: https://id.pockethost.io
Client ID: (from PocketID app settings)
Client Secret: (from PocketID app settings)
Redirect URI: https://your-domain.com/api/auth/oidc/callback

Keycloak

Issuer URL: https://keycloak.example.com/realms/medical
Client ID: pediatric-scribe
Client Secret: (from Credentials tab)
Redirect URI: https://your-domain.com/api/auth/oidc/callback

Azure AD / Entra ID

Issuer URL: https://login.microsoftonline.com/{tenant-id}/v2.0
Client ID: (Application ID from Azure)
Client Secret: (from Certificates & secrets)
Redirect URI: https://your-domain.com/api/auth/oidc/callback

Note: Azure requires app registration in Azure Portal first.

Okta

Issuer URL: https://{your-okta-domain}.okta.com
Client ID: (from Okta application settings)
Client Secret: (from Okta application settings)
Redirect URI: https://your-domain.com/api/auth/oidc/callback

Google (Workspace or Gmail)

Issuer URL: https://accounts.google.com
Client ID: (from Google Cloud Console)
Client Secret: (from Google Cloud Console)
Redirect URI: https://your-domain.com/api/auth/oidc/callback

Note: Google requires OAuth consent screen configuration.


Environment Variables (Alternative to UI Config)

For deployment automation, you can set OIDC config via environment variables instead of the admin UI:

# .env file
OIDC_ENABLED=true
OIDC_ISSUER=https://id.example.com
OIDC_CLIENT_ID=my-client-id
OIDC_CLIENT_SECRET=my-client-secret
OIDC_BUTTON_LABEL=Sign in with PocketID
OIDC_DISABLE_LOCAL_AUTH=false

Note: UI settings take precedence over environment variables. If set in both places, the database values are used.


HIPAA Compliance Notes

OIDC does not transmit PHI to the identity provider. Only authentication-related data (email, name) is exchanged.

For HIPAA compliance:

  • Ensure your OIDC provider has appropriate safeguards
  • Use a self-hosted provider (Keycloak, PocketID) within your secure network
  • Or use a HIPAA-compliant SaaS provider with a BAA
  • Enable audit logging for all SSO login events (automatically logged in audit_log table)

Audit Logging

All SSO login events are logged in the audit_log table:

SELECT * FROM audit_log WHERE action = 'login_oidc' ORDER BY created_at DESC;

Logged fields:

  • User ID
  • Action: login_oidc
  • IP address
  • Details: Issuer URL
  • Timestamp

Support

For issues specific to:

  • This application: Check application logs with docker logs pediatric-ai-scribe
  • Your OIDC provider: Consult provider documentation (PocketID, Keycloak, Azure, etc.)
  • Network/TLS issues: Verify APP_URL matches your reverse proxy configuration

Common log locations:

# Application logs
docker logs pediatric-ai-scribe

# PostgreSQL logs
docker logs pediatric-ai-scribe-postgres