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:
2026-04-09 14:48:50 -06:00
parent 8587fb9378
commit 3b1a35b7f2
9 changed files with 517 additions and 12 deletions
+20 -3
View File
@@ -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;