diff --git a/package-lock.json b/package-lock.json index aa35607..242c688 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,13 @@ { "name": "pediatric-ai-scribe", - "version": "7.0.0", + "version": "8.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pediatric-ai-scribe", - "version": "7.0.0", + "version": "8.0.0", "dependencies": { - "@aws-sdk/client-transcribe-streaming": "^3.700.0", "@marp-team/marp-cli": "^4.3.1", "@marp-team/marp-core": "^4.3.0", "@tiptap/core": "^3.20.4", @@ -30,6 +29,7 @@ "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.16", "openai": "^4.73.0", + "openid-client": "^6.8.2", "pdf-parse": "^1.1.1", "pg": "^8.13.0", "pptxgenjs": "^4.0.1", @@ -3937,6 +3937,15 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4403,6 +4412,15 @@ "node": ">=6.0.0" } }, + "node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4475,6 +4493,19 @@ } } }, + "node_modules/openid-client": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", + "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==", + "license": "MIT", + "dependencies": { + "jose": "^6.1.3", + "oauth4webapi": "^3.8.4" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/orderedmap": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", diff --git a/package.json b/package.json index 1caa55d..1992037 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.16", "openai": "^4.73.0", + "openid-client": "^6.8.2", "pdf-parse": "^1.1.1", "pg": "^8.13.0", "pptxgenjs": "^4.0.1", diff --git a/public/index.html b/public/index.html index 9c0257a..9f9f384 100644 --- a/public/index.html +++ b/public/index.html @@ -50,7 +50,14 @@ - + +
+ + Sign in with SSO +Your email is not verified yet.
Resend verification link diff --git a/public/js/app.js b/public/js/app.js index e775d0a..4f13818 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -536,7 +536,16 @@ function exportToNextcloud(elementId, docType) { } function createSpeechRecognition() { - if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) return null; + if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) { + // Show notice once for browsers without Speech API (Firefox, Safari) + if (!window._speechNoticeShown) { + window._speechNoticeShown = true; + console.warn('[Speech] Browser does not support live transcription (Chrome/Edge required). Server-side transcription will still work.'); + document.addEventListener('recording-started', function() { + showToast('Live preview not available in this browser. Final transcription will work when you stop recording.', 'info'); + }, { once: true }); + } + return null; var SR = window.SpeechRecognition || window.webkitSpeechRecognition; var rec = new SR(); rec.continuous = true; diff --git a/public/js/auth.js b/public/js/auth.js index e5003a6..14c53cb 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -29,8 +29,47 @@ document.addEventListener('DOMContentLoaded', function() { if (authScreen) authScreen.style.display = 'flex'; } - var savedToken = localStorage.getItem(TOKEN_KEY); - if (savedToken) { + // ── Check for SSO token in URL (redirect from OIDC callback) ── + var urlParams = new URLSearchParams(window.location.search); + var ssoToken = urlParams.get('sso_token'); + var ssoError = urlParams.get('error'); + if (ssoToken) { + history.replaceState(null, '', window.location.pathname); + localStorage.setItem(TOKEN_KEY, ssoToken); + fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + ssoToken } }) + .then(function(r) { if (r.ok) return r.json(); throw new Error('invalid'); }) + .then(function(data) { if (data && data.user) { enterApp(data.user, ssoToken); showToast('Welcome, ' + data.user.name + '!', 'success'); } else { showAuthScreen(); clearSession(); } }) + .catch(function() { showAuthScreen(); clearSession(); }); + } else if (ssoError) { + history.replaceState(null, '', window.location.pathname); + var errorMsgs = { invalid_state: 'SSO session expired', expired: 'SSO session expired', no_email: 'Your identity provider did not return an email', disabled: 'Account disabled', sso_failed: 'SSO login failed' }; + showAuthScreen(); + setTimeout(function() { showToast(errorMsgs[ssoError] || 'SSO error', 'error'); }, 300); + } + + // ── Check OIDC status to show/hide SSO button ── + fetch('/api/auth/oidc-status') + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.oidcEnabled) { + var ssoBtn = document.getElementById('btn-sso'); + var ssoDivider = document.getElementById('sso-divider'); + var ssoLabel = document.getElementById('sso-label'); + if (ssoBtn) ssoBtn.style.display = 'block'; + if (ssoDivider) ssoDivider.style.display = 'block'; + if (ssoLabel && data.buttonLabel) ssoLabel.textContent = data.buttonLabel; + if (data.disableLocalAuth) { + // Hide local login form fields, only show SSO + var localFields = document.querySelectorAll('#login-form .form-group, #btn-local-login, #show-register, #show-forgot'); + localFields.forEach(function(el) { el.style.display = 'none'; }); + if (ssoDivider) ssoDivider.style.display = 'none'; + } + } + }) + .catch(function() {}); + + var savedToken = ssoToken || localStorage.getItem(TOKEN_KEY); + if (!ssoToken && savedToken) { // Token exists — verify it silently; auth screen stays hidden during check fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + savedToken } diff --git a/server.js b/server.js index 4d488a7..c86dab6 100644 --- a/server.js +++ b/server.js @@ -108,6 +108,7 @@ app.use(express.static(path.join(__dirname, 'public'), { // Routes app.use('/api/auth', require('./src/routes/auth')); +app.use('/api/auth', require('./src/routes/oidc')); // Learning Hub CMS — must come BEFORE general /api/admin to avoid adminMiddleware conflict // (moderators need access to /api/admin/learning but not other /api/admin routes) diff --git a/src/db/database.js b/src/db/database.js index ee328b3..8175343 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -188,6 +188,7 @@ async function initDatabase() { var migrations = [ "ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'user'", "ALTER TABLE users ADD COLUMN IF NOT EXISTS disabled BOOLEAN DEFAULT false", + "ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_sub TEXT", "CREATE TABLE IF NOT EXISTS saved_encounters (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, label TEXT NOT NULL DEFAULT 'Untitled', enc_type TEXT NOT NULL DEFAULT 'encounter', transcript TEXT DEFAULT '', generated_note TEXT DEFAULT '', partial_data TEXT DEFAULT '{}', status TEXT DEFAULT 'active', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days')", "CREATE TABLE IF NOT EXISTS user_memories (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, category TEXT NOT NULL DEFAULT 'custom', name TEXT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW())", "CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id)", diff --git a/src/routes/oidc.js b/src/routes/oidc.js new file mode 100644 index 0000000..4c43e2b --- /dev/null +++ b/src/routes/oidc.js @@ -0,0 +1,225 @@ +// ============================================================ +// OIDC ROUTES — OpenID Connect SSO authentication +// Compatible with: Azure AD, Okta, Keycloak, PocketID, Google +// ============================================================ + +var express = require('express'); +var router = express.Router(); +var crypto = require('crypto'); +var jwt = require('jsonwebtoken'); +var db = require('../db/database'); +var { JWT_SECRET, authMiddleware, adminMiddleware } = require('../middleware/auth'); + +// In-memory OIDC state store (short-lived, no DB needed) +var pendingStates = {}; + +// Clean expired states every 10 minutes +setInterval(function() { + var now = Date.now(); + Object.keys(pendingStates).forEach(function(k) { + if (pendingStates[k].expires < now) delete pendingStates[k]; + }); +}, 10 * 60 * 1000); + +function setAuthCookie(res, token) { + var isProduction = process.env.NODE_ENV === 'production' || process.env.APP_URL; + res.cookie('ped_auth', token, { + httpOnly: true, + secure: !!isProduction, + sameSite: 'lax', + maxAge: 7 * 24 * 60 * 60 * 1000, + path: '/' + }); +} + +// ── GET OIDC status (public — frontend checks this to show SSO button) ── +router.get('/oidc-status', async function(req, res) { + try { + var enabled = await db.getSetting('oidc.enabled'); + var disableLocal = await db.getSetting('oidc.disable_local_auth'); + var buttonLabel = await db.getSetting('oidc.button_label'); + res.json({ + oidcEnabled: enabled === 'true', + disableLocalAuth: disableLocal === 'true', + buttonLabel: buttonLabel || 'Sign in with SSO' + }); + } catch (e) { + res.json({ oidcEnabled: false, disableLocalAuth: false }); + } +}); + +// ── GET /api/auth/oidc — initiate OIDC login (redirects to IdP) ────────── +router.get('/oidc', async function(req, res) { + try { + var enabled = await db.getSetting('oidc.enabled'); + if (enabled !== 'true') { + return res.status(400).json({ error: 'SSO is not enabled' }); + } + + var issuer = await db.getSetting('oidc.issuer'); + var clientId = await db.getSetting('oidc.client_id'); + + if (!issuer || !clientId) { + return res.status(500).json({ error: 'OIDC not fully configured' }); + } + + var oidc = require('openid-client'); + + var appUrl = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, ''); + var redirectUri = appUrl + '/api/auth/oidc/callback'; + + var config = await oidc.discovery(new URL(issuer), clientId); + + var state = crypto.randomBytes(24).toString('hex'); + var nonce = crypto.randomBytes(24).toString('hex'); + var codeVerifier = oidc.randomPKCECodeVerifier(); + var codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier); + + // Store state for callback verification (5 min TTL) + pendingStates[state] = { + nonce: nonce, + codeVerifier: codeVerifier, + expires: Date.now() + 5 * 60 * 1000 + }; + + var authUrl = oidc.buildAuthorizationUrl(config, { + redirect_uri: redirectUri, + scope: 'openid email profile', + state: state, + nonce: nonce, + code_challenge: codeChallenge, + code_challenge_method: 'S256' + }); + + res.redirect(authUrl.href); + } catch (err) { + console.error('[OIDC] Auth initiation failed:', err.message); + res.status(500).json({ error: 'SSO login failed: ' + err.message }); + } +}); + +// ── GET /api/auth/oidc/callback — handle IdP callback ──────────────────── +router.get('/oidc/callback', async function(req, res) { + var appUrl = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, ''); + + try { + var state = req.query.state; + if (!state || !pendingStates[state]) { + return res.redirect(appUrl + '?error=invalid_state'); + } + + var pending = pendingStates[state]; + delete pendingStates[state]; + + if (pending.expires < Date.now()) { + return res.redirect(appUrl + '?error=expired'); + } + + var issuer = await db.getSetting('oidc.issuer'); + var clientId = await db.getSetting('oidc.client_id'); + var clientSecret = await db.getSetting('oidc.client_secret'); + + var oidc = require('openid-client'); + var redirectUri = appUrl + '/api/auth/oidc/callback'; + + var config = await oidc.discovery(new URL(issuer), clientId, clientSecret || undefined); + + var tokens = await oidc.authorizationCodeGrant(config, new URL(req.protocol + '://' + req.get('host') + req.originalUrl), { + pkceCodeVerifier: pending.codeVerifier, + expectedNonce: pending.nonce, + expectedState: state + }); + + var claims = tokens.claims(); + var sub = claims.sub; + var email = claims.email; + var name = claims.name || claims.preferred_username || email; + + if (!email) { + // Try userinfo endpoint + var userinfo = await oidc.fetchUserInfo(config, tokens.access_token, sub); + email = userinfo.email; + name = name || userinfo.name || userinfo.preferred_username || email; + } + + if (!email) { + return res.redirect(appUrl + '?error=no_email'); + } + + email = email.toLowerCase(); + + // Find or create user + var user = await db.get('SELECT * FROM users WHERE email = ?', [email]); + + if (user) { + // Existing user — link OIDC sub if not already linked + if (!user.oidc_sub) { + await db.run('UPDATE users SET oidc_sub = ?, email_verified = true WHERE id = ?', [sub, user.id]); + } + if (user.disabled) { + return res.redirect(appUrl + '?error=disabled'); + } + } else { + // Auto-create user from OIDC + var userCount = await db.get('SELECT COUNT(*) as count FROM users', []); + var role = (userCount && parseInt(userCount.count) === 0) ? 'admin' : 'user'; + var randomPw = crypto.randomBytes(32).toString('hex'); // Not used for OIDC login + + var result = await db.run( + 'INSERT INTO users (email, password, name, role, email_verified, oidc_sub) VALUES (?, ?, ?, ?, true, ?)', + [email, randomPw, name, role, sub] + ); + user = await db.get('SELECT * FROM users WHERE id = ?', [result.lastInsertRowid]); + } + + // Issue JWT + var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }); + setAuthCookie(res, token); + + await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)', + [user.id, 'login_oidc', req.ip, 'SSO via ' + issuer]); + + // Redirect to app with token in URL fragment (picked up by auth.js) + res.redirect(appUrl + '?sso_token=' + token); + } catch (err) { + console.error('[OIDC] Callback error:', err.message); + res.redirect(appUrl + '?error=sso_failed'); + } +}); + +// ── Admin: GET OIDC config ────────────────────────────────────────────── +router.get('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) { + try { + var keys = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.disable_local_auth', 'oidc.button_label', 'oidc.allowed_ips']; + var config = {}; + for (var i = 0; i < keys.length; i++) { + config[keys[i]] = await db.getSetting(keys[i]) || ''; + } + // Mask client secret + if (config['oidc.client_secret']) { + config['oidc.client_secret'] = '••••••••' + config['oidc.client_secret'].slice(-4); + } + res.json({ success: true, config: config }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// ── Admin: PUT update OIDC config ─────────────────────────────────────── +router.put('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) { + try { + var allowed = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.disable_local_auth', 'oidc.button_label', 'oidc.allowed_ips']; + var updates = req.body; + + for (var i = 0; i < allowed.length; i++) { + var key = allowed[i]; + if (updates[key] !== undefined) { + // Don't overwrite secret with masked value + if (key === 'oidc.client_secret' && updates[key].indexOf('••••') === 0) continue; + await db.setSetting(key, updates[key]); + } + } + + res.json({ success: true }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +module.exports = router;