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
+31
View File
@@ -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;