From a074debd6126023dae7a34067e42f4400fb2a087 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 21 Jun 2026 00:28:39 +0200 Subject: [PATCH] fix(auth): keep the last admin when OIDC claims would demote it (#1274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On OIDC-only instances the bootstrap admin (first SSO user) rarely carries the configured admin claim, so a forced re-login — e.g. after a JWT-secret rotation — re-derived its role purely from claims and demoted it to user, locking the instance out with no recovery. The OIDC login role sync now skips a downgrade that would strip the last remaining admin, and the admin user-update endpoint guards the same case. --- server/src/services/adminService.ts | 10 ++++++++++ server/src/services/oidcService.ts | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index 7cce69c3..a3e067b3 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -141,6 +141,16 @@ export function updateUser(id: string, data: { username?: string; email?: string } const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null; + // Don't let the admin UI demote the last remaining admin — that would leave the + // instance with no one able to manage it (and on OIDC-only setups, no recovery). #1274 + if (role && role !== 'admin') { + const current = db.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role?: string } | undefined; + if (current?.role === 'admin') { + const adminCount = (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count; + if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 }; + } + } + db.prepare(` UPDATE users SET username = COALESCE(?, username), diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts index 774faa83..558b3a12 100644 --- a/server/src/services/oidcService.ts +++ b/server/src/services/oidcService.ts @@ -385,8 +385,20 @@ export function findOrCreateUser( if (process.env.OIDC_ADMIN_VALUE) { const newRole = resolveOidcRole(userInfo, false); if (user.role !== newRole) { - db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id); - user = { ...user, role: newRole } as User; + // Never let the claim-based downgrade strip the last admin. The bootstrap + // admin (first SSO user) usually doesn't carry the admin claim, so a forced + // re-login — e.g. after a JWT-secret rotation — would otherwise demote it and + // lock an OIDC-only instance out for good. #1274 + const demotingLastAdmin = + user.role === 'admin' && + newRole !== 'admin' && + (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count <= 1; + if (demotingLastAdmin) { + console.warn(`[OIDC] Kept admin role for user ${user.id}: their OIDC claims map to '${newRole}', but they are the only admin — demoting would lock the instance out.`); + } else { + db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id); + user = { ...user, role: newRole } as User; + } } } return { user };