feat: add OIDC login with account linking
Add OpenID Connect as an alternative login method. Users can sign in via an external identity provider (e.g., Authentik, Keycloak, Google). - OIDC settings configured in admin UI (discovery URL, client ID/secret, redirect URI, button label, enable/disable toggle) - PKCE-based authorization code flow with state and nonce validation - Admin can manually link any user's OIDC identity (sub/issuer fields) - Self-service linking: logged-in users can link/unlink their own account - SSO button conditionally shown on login page when OIDC is enabled - Username in header now clickable to open profile for all users - Callback errors/success communicated via URL hash fragments
This commit is contained in:
@@ -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 (
|
||||
|
||||
+182
-1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+20
-3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user