Add OpenID Connect SSO + Firefox speech notice

OIDC/SSO:
- New /api/auth/oidc route with PKCE for secure authorization
- Supports Azure AD, Okta, Keycloak, PocketID, Google, any OIDC provider
- Admin configurable: issuer, client ID/secret, button label
- Option to disable local auth (force SSO only)
- Auto-creates users on first SSO login, links existing by email
- SSO button on login page, hidden until admin enables OIDC

Firefox:
- Show info toast on first recording that live preview requires
  Chrome/Edge; server-side transcription still works in all browsers
This commit is contained in:
Daniel Onyejesi 2026-03-25 18:54:44 -04:00
parent ce4ef822ba
commit 8e544ad5b9
8 changed files with 321 additions and 7 deletions

37
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -50,7 +50,14 @@
<label>2FA Code</label>
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
</div>
<button type="submit" class="btn-auth">Sign In</button>
<button type="submit" class="btn-auth" id="btn-local-login">Sign In</button>
<div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;">
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
<hr style="border:none;border-top:1px solid #e5e7eb;position:absolute;top:50%;left:0;right:0;margin:0;">
</div>
<a href="/api/auth/oidc" id="btn-sso" class="btn-auth" style="display:none;text-align:center;text-decoration:none;background:linear-gradient(135deg,#0f172a,#334155);margin-top:0;">
<i class="fas fa-shield-halved"></i> <span id="sso-label">Sign in with SSO</span>
</a>
<div id="resend-verify-box" class="hidden" style="margin:12px 0;padding:12px;background:#fef3c7;border-radius:8px;text-align:center;">
<p style="margin:0 0 8px;font-size:13px;color:#92400e;">Your email is not verified yet.</p>
<a href="#" id="resend-verify-link" style="font-size:13px;font-weight:600;color:#2563eb;">Resend verification link</a>

View file

@ -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;

View file

@ -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 }

View file

@ -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)

View file

@ -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)",

225
src/routes/oidc.js Normal file
View file

@ -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;