feat: roles follow the SSO's groups, on every sign-in
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 8s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 13:54:12 +02:00
parent e306c3ce28
commit f748e02063
3 changed files with 81 additions and 4 deletions

View file

@ -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

View file

@ -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;

34
test/oidc-roles.test.js Normal file
View file

@ -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'\]/);
});