diff --git a/public/js/app.js b/public/js/app.js
index dceb445..3e92e15 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -52,6 +52,23 @@ async function checkAuth() {
showLoginOverlay();
return false;
}
+
+ // OIDC callback error/success detection
+ if (location.hash.startsWith('#oidc-error=')) {
+ const msg = decodeURIComponent(location.hash.slice('#oidc-error='.length));
+ history.replaceState(null, '', location.pathname);
+ showLoginSection('login-form-section');
+ const errEl = document.getElementById('login-error');
+ errEl.textContent = msg;
+ errEl.hidden = false;
+ showLoginOverlay();
+ return false;
+ }
+ if (location.hash === '#oidc-linked') {
+ history.replaceState(null, '', location.pathname);
+ // Fall through to normal auth check — user is still logged in
+ }
+
// Is there already a session?
const res = await fetch('/api/auth/me');
if (res.ok) {
@@ -68,10 +85,27 @@ async function checkAuth() {
} else {
showLoginSection('login-form-section');
}
+ // Show SSO button if OIDC is enabled
+ loadOidcLoginButton();
showLoginOverlay();
return false;
}
+async function loadOidcLoginButton() {
+ try {
+ const res = await fetch('/api/auth/oidc/config');
+ if (!res.ok) return;
+ const cfg = await res.json();
+ const section = document.getElementById('oidc-login-section');
+ if (cfg.enabled) {
+ document.getElementById('btn-oidc-login').textContent = cfg.button_label || 'Sign in with SSO';
+ section.hidden = false;
+ } else {
+ section.hidden = true;
+ }
+ } catch { /* ignore */ }
+}
+
async function submitLogin() {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
@@ -158,12 +192,23 @@ function applyRoleUI() {
let usersState = { users: [], editingId: null };
function openUsersModal() {
+ const isAdmin = state.user && state.user.role === 'admin';
document.getElementById('user-form-error').hidden = true;
+ document.getElementById('users-title').textContent = isAdmin ? 'Manage Users' : 'My Account';
document.getElementById('users-overlay').classList.add('open');
document.getElementById('users-modal').classList.add('open');
- loadUsers();
- renderUfAccountCheckboxes();
- if (state.user && state.user.role === 'admin') loadSmtpSettings();
+ // Admin-only sections
+ document.getElementById('users-list').hidden = !isAdmin;
+ document.getElementById('user-form-section').hidden = !isAdmin;
+ document.getElementById('smtp-settings-section').hidden = !isAdmin;
+ document.getElementById('oidc-settings-section').hidden = !isAdmin;
+ if (isAdmin) {
+ loadUsers();
+ renderUfAccountCheckboxes();
+ loadSmtpSettings();
+ loadOidcSettings();
+ }
+ loadOidcLinkStatus();
}
function closeUsersModal() {
@@ -204,8 +249,9 @@ function renderUsersList() {
const name = escHtml(a ? (a.company1 || `Account ${a.account_id}`) : `#${ua.account_id}`);
return `${name}
- | ${escHtml(u.username)}${isSelf ? ' (you)' : ''} |
+ ${escHtml(u.username)}${isSelf ? ' (you)' : ''}${oidcTag} |
${roleBadge(u.role)} |
${accountsLabel} |
@@ -253,6 +299,10 @@ function startUserEdit(userId) {
document.getElementById('btn-save-user').textContent = 'Save Changes';
document.getElementById('btn-cancel-user-edit').hidden = false;
document.getElementById('user-form-error').hidden = true;
+ // OIDC fields
+ document.getElementById('uf-oidc-sub').value = u.oidc_sub || '';
+ document.getElementById('uf-oidc-issuer').value = u.oidc_issuer || '';
+ document.getElementById('uf-oidc-group').hidden = false;
renderUfAccountCheckboxes();
document.getElementById('uf-username').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
@@ -268,6 +318,10 @@ function cancelUserEdit() {
document.getElementById('btn-save-user').textContent = 'Add User';
document.getElementById('btn-cancel-user-edit').hidden = true;
document.getElementById('user-form-error').hidden = true;
+ // OIDC fields
+ document.getElementById('uf-oidc-sub').value = '';
+ document.getElementById('uf-oidc-issuer').value = '';
+ document.getElementById('uf-oidc-group').hidden = true;
renderUfAccountCheckboxes();
}
@@ -296,6 +350,8 @@ async function saveUser() {
const body = { username, email, role, accounts };
if (password) body.password = password;
if (usersState.editingId) {
+ body.oidc_sub = document.getElementById('uf-oidc-sub').value.trim();
+ body.oidc_issuer = document.getElementById('uf-oidc-issuer').value.trim();
await apiFetch('PUT', `/api/users/${usersState.editingId}`, body);
} else {
await apiFetch('POST', '/api/users', body);
@@ -1603,6 +1659,83 @@ async function saveSmtpSettings() {
}
}
+// ── OIDC settings ────────────────────────────────────────────────────────────
+
+async function loadOidcSettings() {
+ try {
+ const s = await apiFetch('GET', '/api/settings/oidc');
+ if (!s) return;
+ document.getElementById('oidc-enabled').value = s.enabled ? '1' : '0';
+ document.getElementById('oidc-discovery-url').value = s.discovery_url;
+ document.getElementById('oidc-client-id').value = s.client_id;
+ document.getElementById('oidc-redirect-uri').value = s.redirect_uri;
+ document.getElementById('oidc-button-label').value = s.button_label;
+ document.getElementById('oidc-secret-hint').textContent = s.has_secret ? '(leave blank to keep)' : '';
+ } catch (_) {}
+}
+
+async function saveOidcSettings() {
+ const errEl = document.getElementById('oidc-error');
+ const successEl = document.getElementById('oidc-success');
+ const btn = document.getElementById('btn-save-oidc');
+ errEl.hidden = true; successEl.hidden = true;
+ btn.disabled = true;
+ try {
+ await apiFetch('PUT', '/api/settings/oidc', {
+ enabled: document.getElementById('oidc-enabled').value === '1',
+ discovery_url: document.getElementById('oidc-discovery-url').value.trim(),
+ client_id: document.getElementById('oidc-client-id').value.trim(),
+ client_secret: document.getElementById('oidc-client-secret').value,
+ redirect_uri: document.getElementById('oidc-redirect-uri').value.trim(),
+ button_label: document.getElementById('oidc-button-label').value.trim(),
+ });
+ successEl.textContent = 'Saved.'; successEl.hidden = false;
+ document.getElementById('oidc-client-secret').value = '';
+ await loadOidcSettings();
+ setTimeout(() => { successEl.hidden = true; }, 3000);
+ } catch (err) {
+ errEl.textContent = err.message; errEl.hidden = false;
+ } finally {
+ btn.disabled = false;
+ }
+}
+
+// ── OIDC self-service linking ────────────────────────────────────────────────
+
+async function loadOidcLinkStatus() {
+ try {
+ const cfg = await fetch('/api/auth/oidc/config').then(r => r.json());
+ const section = document.getElementById('oidc-link-section');
+ if (!cfg.enabled) { section.hidden = true; return; }
+ section.hidden = false;
+
+ const me = await apiFetch('GET', '/api/auth/me');
+ const statusEl = document.getElementById('oidc-link-status');
+ const linkBtn = document.getElementById('btn-oidc-link');
+ const unlinkBtn = document.getElementById('btn-oidc-unlink');
+
+ if (me.oidc_linked) {
+ statusEl.textContent = 'Your account is linked to SSO.';
+ linkBtn.hidden = true;
+ unlinkBtn.hidden = false;
+ } else {
+ statusEl.textContent = 'Link your account to sign in with SSO.';
+ linkBtn.hidden = false;
+ unlinkBtn.hidden = true;
+ }
+ } catch (_) {}
+}
+
+async function unlinkOidc() {
+ if (!confirm('Unlink your SSO identity? You will need to use your password to sign in.')) return;
+ try {
+ await apiFetch('POST', '/api/auth/oidc/unlink');
+ await loadOidcLinkStatus();
+ } catch (err) {
+ alert(err.message);
+ }
+}
+
// ── Initialization ───────────────────────────────────────────────────────────
async function init() {
@@ -1796,6 +1929,7 @@ async function init() {
// User management
document.getElementById('btn-users').addEventListener('click', openUsersModal);
+ document.getElementById('header-username').addEventListener('click', openUsersModal);
document.getElementById('btn-close-users').addEventListener('click', closeUsersModal);
document.getElementById('users-overlay').addEventListener('click', closeUsersModal);
document.getElementById('users-list').addEventListener('click', e => {
@@ -1809,6 +1943,8 @@ async function init() {
document.getElementById('uf-role').addEventListener('change', renderUfAccountCheckboxes);
document.getElementById('btn-change-password').addEventListener('click', changeOwnPassword);
document.getElementById('btn-save-smtp').addEventListener('click', saveSmtpSettings);
+ document.getElementById('btn-save-oidc').addEventListener('click', saveOidcSettings);
+ document.getElementById('btn-oidc-unlink').addEventListener('click', unlinkOidc);
// Add checking account
document.getElementById('btn-add-account').addEventListener('click', openWizard);
diff --git a/src/db/database.js b/src/db/database.js
index eb705aa..61e544e 100644
--- a/src/db/database.js
+++ b/src/db/database.js
@@ -139,6 +139,17 @@ db.exec(`
)
`);
+// Migration: add OIDC columns to users
+const usersInfo2 = db.prepare('PRAGMA table_info(users)').all();
+if (!usersInfo2.some(c => c.name === 'oidc_sub')) {
+ db.exec(`
+ ALTER TABLE users ADD COLUMN oidc_sub TEXT;
+ ALTER TABLE users ADD COLUMN oidc_issuer TEXT;
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc ON users(oidc_issuer, oidc_sub)
+ WHERE oidc_sub IS NOT NULL;
+ `);
+}
+
// Migration: create settings table
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
diff --git a/src/routes/auth.js b/src/routes/auth.js
index dbfc35c..9f5f83d 100644
--- a/src/routes/auth.js
+++ b/src/routes/auth.js
@@ -123,7 +123,13 @@ router.get('/me', (req, res) => {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Not authenticated.' });
}
- res.json({ id: req.session.userId, username: req.session.username, role: req.session.role });
+ const user = db.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(req.session.userId);
+ res.json({
+ id: req.session.userId,
+ username: req.session.username,
+ role: req.session.role,
+ oidc_linked: !!(user && user.oidc_sub),
+ });
});
// POST /api/auth/change-password — any logged-in user can change their own password
@@ -200,5 +206,180 @@ router.post('/reset-password', async (req, res) => {
res.json({ ok: true });
});
+// ── OIDC helpers ─────────────────────────────────────────────────────────────
+
+function getOidcSettings() {
+ const rows = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'oidc_%'").all();
+ const s = Object.fromEntries(rows.map(r => [r.key.replace('oidc_', ''), r.value || '']));
+ return {
+ enabled: s.enabled === '1',
+ discovery_url: s.discovery_url || '',
+ client_id: s.client_id || '',
+ client_secret: s.client_secret || '',
+ redirect_uri: s.redirect_uri || '',
+ button_label: s.button_label || 'Sign in with SSO',
+ };
+}
+
+async function getOidcClient(settings) {
+ const { Issuer } = require('openid-client');
+ const issuer = await Issuer.discover(settings.discovery_url);
+ return new issuer.Client({
+ client_id: settings.client_id,
+ client_secret: settings.client_secret,
+ redirect_uris: [settings.redirect_uri],
+ response_types: ['code'],
+ });
+}
+
+// GET /api/auth/oidc/config — public, returns whether OIDC is enabled + button label
+router.get('/oidc/config', (req, res) => {
+ const s = getOidcSettings();
+ res.json({ enabled: s.enabled, button_label: s.button_label });
+});
+
+// GET /api/auth/oidc/authorize — initiates the OIDC flow (redirect to provider)
+router.get('/oidc/authorize', async (req, res) => {
+ try {
+ const settings = getOidcSettings();
+ if (!settings.enabled) return res.status(400).json({ error: 'OIDC is not enabled.' });
+
+ const { generators } = require('openid-client');
+ const client = await getOidcClient(settings);
+
+ const code_verifier = generators.codeVerifier();
+ const code_challenge = generators.codeChallenge(code_verifier);
+ const state = generators.state();
+ const nonce = generators.nonce();
+
+ req.session.oidc = { code_verifier, state, nonce };
+
+ const authUrl = client.authorizationUrl({
+ scope: 'openid email profile',
+ state,
+ nonce,
+ code_challenge,
+ code_challenge_method: 'S256',
+ });
+
+ // Ensure session is persisted before redirecting (saveUninitialized is false)
+ req.session.save(() => res.redirect(authUrl));
+ } catch (err) {
+ console.error('[oidc] authorize error:', err.message);
+ res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO login.'));
+ }
+});
+
+// GET /api/auth/oidc/callback — handles the provider redirect
+router.get('/oidc/callback', async (req, res) => {
+ try {
+ const settings = getOidcSettings();
+ if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.'));
+
+ const oidcSession = req.session.oidc;
+ if (!oidcSession) return res.redirect('/#oidc-error=' + encodeURIComponent('Session expired. Please try again.'));
+
+ const client = await getOidcClient(settings);
+ const params = client.callbackParams(req);
+
+ const tokenSet = await client.callback(settings.redirect_uri, params, {
+ code_verifier: oidcSession.code_verifier,
+ state: oidcSession.state,
+ nonce: oidcSession.nonce,
+ });
+
+ const claims = tokenSet.claims();
+ const sub = claims.sub;
+ const issuer = claims.iss;
+
+ delete req.session.oidc;
+
+ // Self-service linking flow
+ if (oidcSession.linking && oidcSession.linkUserId) {
+ const existing = db.prepare(
+ 'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?'
+ ).get(issuer, sub, oidcSession.linkUserId);
+ if (existing) {
+ return res.redirect('/#oidc-error=' + encodeURIComponent('This identity is already linked to another account.'));
+ }
+
+ db.prepare("UPDATE users SET oidc_sub = ?, oidc_issuer = ?, updated_at = datetime('now') WHERE id = ?")
+ .run(sub, issuer, oidcSession.linkUserId);
+ return res.redirect('/#oidc-linked');
+ }
+
+ // Login flow — look up user by OIDC identity
+ const user = db.prepare(
+ 'SELECT id, username, role FROM users WHERE oidc_issuer = ? AND oidc_sub = ?'
+ ).get(issuer, sub);
+
+ if (!user) {
+ return res.redirect('/#oidc-error=' + encodeURIComponent(
+ 'No account is linked to this identity. Ask an admin to link your account, or sign in with your password and link it yourself.'
+ ));
+ }
+
+ req.session.userId = user.id;
+ req.session.username = user.username;
+ req.session.role = user.role;
+
+ // Load account access into session (mirrors login behavior)
+ if (user.role !== 'admin') {
+ const accts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(user.id);
+ req.session.accounts = accts;
+ }
+
+ res.redirect('/');
+ } catch (err) {
+ console.error('[oidc] callback error:', err.message);
+ res.redirect('/#oidc-error=' + encodeURIComponent('SSO login failed. Please try again.'));
+ }
+});
+
+// GET /api/auth/oidc/link — logged-in user initiates linking flow
+router.get('/oidc/link', async (req, res) => {
+ if (!req.session || !req.session.userId) {
+ return res.redirect('/#oidc-error=' + encodeURIComponent('You must be signed in to link your account.'));
+ }
+
+ try {
+ const settings = getOidcSettings();
+ if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.'));
+
+ const { generators } = require('openid-client');
+ const client = await getOidcClient(settings);
+
+ const code_verifier = generators.codeVerifier();
+ const code_challenge = generators.codeChallenge(code_verifier);
+ const state = generators.state();
+ const nonce = generators.nonce();
+
+ req.session.oidc = { code_verifier, state, nonce, linking: true, linkUserId: req.session.userId };
+
+ const authUrl = client.authorizationUrl({
+ scope: 'openid email profile',
+ state,
+ nonce,
+ code_challenge,
+ code_challenge_method: 'S256',
+ });
+
+ req.session.save(() => res.redirect(authUrl));
+ } catch (err) {
+ console.error('[oidc] link error:', err.message);
+ res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO linking.'));
+ }
+});
+
+// POST /api/auth/oidc/unlink — logged-in user removes their own OIDC link
+router.post('/oidc/unlink', (req, res) => {
+ if (!req.session || !req.session.userId) {
+ return res.status(401).json({ error: 'Not authenticated.' });
+ }
+ db.prepare("UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL, updated_at = datetime('now') WHERE id = ?")
+ .run(req.session.userId);
+ res.json({ ok: true });
+});
+
module.exports = router;
module.exports.validatePassword = validatePassword;
diff --git a/src/routes/settings.js b/src/routes/settings.js
index d220c62..a244035 100644
--- a/src/routes/settings.js
+++ b/src/routes/settings.js
@@ -36,4 +36,35 @@ router.put('/smtp', (req, res) => {
res.json({ ok: true });
});
+// GET /api/settings/oidc
+router.get('/oidc', (req, res) => {
+ const rows = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'oidc_%'").all();
+ const s = Object.fromEntries(rows.map(r => [r.key.replace('oidc_', ''), r.value || '']));
+ res.json({
+ enabled: s.enabled === '1',
+ discovery_url: s.discovery_url || '',
+ client_id: s.client_id || '',
+ redirect_uri: s.redirect_uri || '',
+ button_label: s.button_label || 'Sign in with SSO',
+ has_secret: !!(rows.find(r => r.key === 'oidc_client_secret') || {}).value,
+ });
+});
+
+// PUT /api/settings/oidc
+router.put('/oidc', (req, res) => {
+ const { enabled, discovery_url, client_id, client_secret, redirect_uri, button_label } = req.body;
+ const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)');
+ db.transaction(() => {
+ upsert.run('oidc_enabled', enabled ? '1' : '0');
+ upsert.run('oidc_discovery_url', discovery_url || '');
+ upsert.run('oidc_client_id', client_id || '');
+ upsert.run('oidc_redirect_uri', redirect_uri || '');
+ upsert.run('oidc_button_label', button_label || 'Sign in with SSO');
+ if (client_secret !== undefined && client_secret !== '') {
+ upsert.run('oidc_client_secret', client_secret);
+ }
+ })();
+ res.json({ ok: true });
+});
+
module.exports = router;
diff --git a/src/routes/users.js b/src/routes/users.js
index b573fd2..3effcb6 100644
--- a/src/routes/users.js
+++ b/src/routes/users.js
@@ -11,7 +11,7 @@ const { validatePassword } = require('./auth');
router.use(requireAuth, requireAdmin);
function userWithAccounts(id) {
- const user = db.prepare('SELECT id, username, email, role, created_at FROM users WHERE id = ?').get(id);
+ const user = db.prepare('SELECT id, username, email, role, oidc_sub, oidc_issuer, created_at FROM users WHERE id = ?').get(id);
if (!user) return null;
user.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(id);
return user;
@@ -19,7 +19,7 @@ function userWithAccounts(id) {
// GET /api/users
router.get('/', (req, res) => {
- const users = db.prepare('SELECT id, username, email, role, created_at FROM users ORDER BY id ASC').all();
+ const users = db.prepare('SELECT id, username, email, role, oidc_sub, oidc_issuer, created_at FROM users ORDER BY id ASC').all();
users.forEach(u => {
u.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(u.id);
});
@@ -60,7 +60,7 @@ router.put('/:id', async (req, res) => {
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found.' });
- const { username, password, role, accounts, email } = req.body;
+ const { username, password, role, accounts, email, oidc_sub, oidc_issuer } = req.body;
if (role && !['admin', 'editor', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'Invalid role.' });
@@ -94,6 +94,23 @@ router.put('/:id', async (req, res) => {
.run(hash, req.params.id);
}
+ // OIDC linking — admin can set or clear oidc_sub/oidc_issuer
+ if (oidc_sub !== undefined) {
+ const newSub = oidc_sub ? oidc_sub.trim() : null;
+ const newIssuer = oidc_issuer ? oidc_issuer.trim() : null;
+ if (newSub && !newIssuer) {
+ return res.status(400).json({ error: 'OIDC issuer is required when setting OIDC subject.' });
+ }
+ if (newSub) {
+ const existing = db.prepare(
+ 'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?'
+ ).get(newIssuer, newSub, req.params.id);
+ if (existing) return res.status(409).json({ error: 'This OIDC identity is already linked to another user.' });
+ }
+ db.prepare("UPDATE users SET oidc_sub = ?, oidc_issuer = ?, updated_at = datetime('now') WHERE id = ?")
+ .run(newSub, newSub ? newIssuer : null, req.params.id);
+ }
+
if (Array.isArray(accounts)) {
db.prepare('DELETE FROM user_accounts WHERE user_id = ?').run(req.params.id);
const effectiveRole = role || user.role;
|