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:
parent
ce4ef822ba
commit
8e544ad5b9
8 changed files with 321 additions and 7 deletions
37
package-lock.json
generated
37
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
225
src/routes/oidc.js
Normal 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;
|
||||
Loading…
Reference in a new issue