From f748e02063866af39d4ab8064e187def86f77f03 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 13:54:12 +0200 Subject: [PATCH] feat: roles follow the SSO's groups, on every sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both PedsHub apps now take admin and moderator from the same two Authentik groups. oidc.admin_groups and oidc.moderator_groups name them; unset means local roles stand. Applied at every sign-in so removal at the SSO demotes here, and never applied to the last admin — a group edit must not be able to lock everyone out of the panel. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- docs/authentication.md | 7 +++++-- src/routes/oidc.js | 44 +++++++++++++++++++++++++++++++++++++++-- test/oidc-roles.test.js | 34 +++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 test/oidc-roles.test.js diff --git a/docs/authentication.md b/docs/authentication.md index 615e437b..68ed0412 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -10,8 +10,11 @@ signed state cookie, PKCE, nonce, `email_verified` required before an existing local account is linked, `sub` mismatch refused, session row written before the cookie is set. New accounts are created at the SSO from an invitation link (`invite.py` there); an SSO account whose email matches a local one signs into -that account. The sections below describe the local machinery that remains -behind the switch. +that account. Roles follow the SSO's groups on every sign-in when +`oidc.admin_groups` / `oidc.moderator_groups` name them (production: +`pedshub-admins`, `pedshub-moderators`; anyone in neither is a user); the last +admin is never demoted by a claim. The sections below describe the local +machinery that remains behind the switch. ## Password hashing diff --git a/src/routes/oidc.js b/src/routes/oidc.js index 6a95f583..25a381b1 100644 --- a/src/routes/oidc.js +++ b/src/routes/oidc.js @@ -97,6 +97,24 @@ router.get('/oidc-status', async function(req, res) { } }); +// Which role a set of SSO group names earns. Empty settings mean "off": roles +// stay whatever they are locally. A claim that is a string is read as a +// comma- or space-separated list, since providers differ; names are compared +// case-blind; admin beats moderator; a member of neither is a user. +function parseGroupList(value) { + return String(value || '').split(/[,\s]+/).map(function (g) { return g.trim().toLowerCase(); }).filter(Boolean); +} +async function roleFromGroups(claim) { + var admins = parseGroupList(await db.getSetting('oidc.admin_groups')); + var moderators = parseGroupList(await db.getSetting('oidc.moderator_groups')); + if (!admins.length && !moderators.length) return null; + if (claim == null) return null; + var groups = Array.isArray(claim) ? claim.map(function (g) { return String(g).toLowerCase(); }) : parseGroupList(claim); + if (admins.some(function (g) { return groups.indexOf(g) !== -1; })) return 'admin'; + if (moderators.some(function (g) { return groups.indexOf(g) !== -1; })) return 'moderator'; + return 'user'; +} + // ── GET /api/auth/oidc — initiate OIDC login (redirects to IdP) ────────── router.get('/oidc', async function(req, res) { try { @@ -239,6 +257,25 @@ router.get('/oidc/callback', async function(req, res) { user = await db.get('SELECT * FROM users WHERE id = ?', [result.lastInsertRowid]); } + // The role comes from the SSO's groups, on every sign-in, when the admin + // has said which groups mean what (oidc.admin_groups, oidc.moderator_groups). + // Applied every time rather than only at creation, so removal at the SSO + // demotes here too. The last admin is never demoted by a claim: a group + // edit at the SSO must not be able to lock everyone out of this panel. + var mapped = await roleFromGroups(claims.groups); + if (mapped && mapped !== user.role) { + var lastAdmin = user.role === 'admin' && mapped !== 'admin' && + parseInt((await db.get("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND disabled IS NOT TRUE", [])).count, 10) <= 1; + if (lastAdmin) { + console.warn('[OIDC] not demoting the last admin (user ' + user.id + ') on the SSO\'s say-so'); + } else { + await db.run('UPDATE users SET role = ? WHERE id = ?', [mapped, user.id]); + await db.run('INSERT INTO audit_log (user_id, action, category, details, ip_address) VALUES (?, ?, ?, ?, ?)', + [user.id, 'oidc_role', 'auth', 'Role ' + user.role + ' → ' + mapped + ' from SSO groups', req.ip]).catch(function(){}); + user.role = mapped; + } + } + // Issue JWT var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: isMobileClient(req) ? '365d' : '30d' }); @@ -263,7 +300,7 @@ router.get('/oidc/callback', async function(req, res) { // ── 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 keys = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.disable_local_auth', 'oidc.button_label', 'oidc.allowed_ips', 'oidc.admin_groups', 'oidc.moderator_groups']; var config = {}; for (var i = 0; i < keys.length; i++) { config[keys[i]] = await db.getSetting(keys[i]) || ''; @@ -279,7 +316,7 @@ router.get('/oidc/config', authMiddleware, adminMiddleware, async function(req, // ── 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 allowed = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.disable_local_auth', 'oidc.button_label', 'oidc.allowed_ips', 'oidc.admin_groups', 'oidc.moderator_groups']; var updates = req.body; for (var i = 0; i < allowed.length; i++) { @@ -296,3 +333,6 @@ router.put('/oidc/config', authMiddleware, adminMiddleware, async function(req, }); module.exports = router; + +module.exports.roleFromGroups = roleFromGroups; +module.exports.parseGroupList = parseGroupList; diff --git a/test/oidc-roles.test.js b/test/oidc-roles.test.js new file mode 100644 index 00000000..789dfad9 --- /dev/null +++ b/test/oidc-roles.test.js @@ -0,0 +1,34 @@ +// Roles come from the SSO's groups on every sign-in, when the admin has said +// which groups mean what; the last admin is never demoted by a claim. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'src/routes/oidc.js'), 'utf8'); +function withSettings(settings) { + const db = { getSetting: async k => settings[k] || '' }; + const slice = src.slice(src.indexOf('function parseGroupList'), src.indexOf('// ── GET /api/auth/oidc — initiate')); + return new Function('db', slice + '; return roleFromGroups;')(db); +} + +test('groups map to roles: admin beats moderator, neither is a user, off when unset', async () => { + const on = withSettings({ 'oidc.admin_groups': 'pedshub-admins', 'oidc.moderator_groups': 'pedshub-moderators, editors' }); + assert.equal(await on(['pedshub-members', 'pedshub-admins', 'pedshub-moderators']), 'admin'); + assert.equal(await on(['pedshub-members', 'Editors']), 'moderator', 'case-blind'); + assert.equal(await on(['pedshub-members']), 'user'); + assert.equal(await on('pedshub-members pedshub-moderators'), 'moderator', 'a string claim is a list'); + assert.equal(await on(undefined), null, 'no claim: leave the role alone'); + const off = withSettings({}); + assert.equal(await off(['pedshub-admins']), null, 'unset settings: local roles stand'); +}); + +test('the callback applies the mapping every sign-in and spares the last admin', () => { + const cb = src.slice(src.indexOf("router.get('/oidc/callback'")); + assert.match(cb, /var mapped = await roleFromGroups\(claims\.groups\);/); + assert.match(cb, /if \(mapped && mapped !== user\.role\)/); + assert.match(cb, /SELECT COUNT\(\*\) AS count FROM users WHERE role = 'admin' AND disabled IS NOT TRUE/); + assert.match(cb, /not demoting the last admin/); + assert.match(cb, /UPDATE users SET role = \? WHERE id = \?/); + assert.match(src, /'oidc\.admin_groups', 'oidc\.moderator_groups'\]/); +});