Security: - Add session management: users can view/revoke active sessions in Settings - Add password change in Settings (requires current password, HIBP check) - Force logout all sessions on password reset - Fix logout to destroy server-side session (was only clearing cookie) - Add trust proxy for correct client IP in rate limiting and audit logs - Add CORS support for multiple domains (CORS_ORIGINS env var) - Add HIBP breach check endpoint and inline warnings on password fields Audit logging: - Add audit logging to all 24 PHI-handling endpoints across 13 route files - Covers: generation, transcription, TTS, refine, encounters, documents, Nextcloud - All fire-and-forget (no response delay) AI improvements: - Refine now includes original source material (transcript, notes, labs) so AI can reference the full input when modifying output - Add correction tracking (trackAIOutput) to sick visit and well visit tabs - Fix sickvisit missing from encounter save noteIdMap UI fixes: - Non-blocking busy bar for transcription and AI generation (replaces full-screen overlay) - Fix encounter recording: hide record button during recording (was showing two stop buttons) - Fix ROS/PE "All WNL" stacking duplicate event handlers; add Clear buttons - Enlarge AI instructions textarea in Learning Hub CMS Domain: - Primary domain now app.pedshub.com, with scribe.pedshub.com and peds.danvics.com as CORS origins
233 lines
8.7 KiB
JavaScript
233 lines
8.7 KiB
JavaScript
// ============================================================
|
|
// 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');
|
|
var { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
|
|
|
|
// 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);
|
|
|
|
// Create session record
|
|
var ssoSessionId = generateSessionId();
|
|
try {
|
|
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
|
|
[ssoSessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
|
|
} catch (e) { /* table may not exist yet */ }
|
|
|
|
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 — token is in httpOnly cookie, pass session ID
|
|
res.redirect(appUrl + '?sso=ok&sid=' + ssoSessionId);
|
|
} 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;
|