From 4a4643f33f9fe5a137e59363883ec388c30bdd4c Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 30 Mar 2026 15:12:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20OIDC=20claim-based=20admin=20role=20ass?= =?UTF-8?q?ignment=20=E2=80=94=20closes=20#93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New environment variables: - OIDC_ADMIN_CLAIM (default: "groups") — which claim to check - OIDC_ADMIN_VALUE (e.g. "app-trek-admins") — value that grants admin Admin role is resolved on every OIDC login: - New users get admin if their claim matches - Existing users have their role updated dynamically - Removing a user from the group revokes admin on next login - First user is always admin regardless of claims - No config = previous behavior (first user admin, rest user) Supports array claims (groups: ["a", "b"]) and string claims. --- server/src/routes/oidc.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts index 404f017..cd7323f 100644 --- a/server/src/routes/oidc.ts +++ b/server/src/routes/oidc.ts @@ -24,6 +24,9 @@ interface OidcUserInfo { email?: string; name?: string; preferred_username?: string; + groups?: string[]; + roles?: string[]; + [key: string]: unknown; } const router = express.Router(); @@ -85,6 +88,23 @@ function generateToken(user: { id: number; username: string; email: string; role ); } +// Check if user should be admin based on OIDC claims +// Env: OIDC_ADMIN_CLAIM (default: "groups"), OIDC_ADMIN_VALUE (required, e.g. "app-trek-admins") +function resolveOidcRole(userInfo: OidcUserInfo, isFirstUser: boolean): 'admin' | 'user' { + if (isFirstUser) return 'admin'; + const adminValue = process.env.OIDC_ADMIN_VALUE; + if (!adminValue) return 'user'; // No claim mapping configured + const claimKey = process.env.OIDC_ADMIN_CLAIM || 'groups'; + const claimData = userInfo[claimKey]; + if (Array.isArray(claimData)) { + return claimData.some(v => String(v) === adminValue) ? 'admin' : 'user'; + } + if (typeof claimData === 'string') { + return claimData === adminValue ? 'admin' : 'user'; + } + return 'user'; +} + function frontendUrl(path: string): string { const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173'; return base + path; @@ -190,6 +210,14 @@ router.get('/callback', async (req: Request, res: Response) => { if (!user.oidc_sub) { db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id); } + // Update role based on OIDC claims on every login (if claim mapping is configured) + 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; + } + } } else { const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; const isFirstUser = userCount === 0; @@ -201,7 +229,7 @@ router.get('/callback', async (req: Request, res: Response) => { } } - const role = isFirstUser ? 'admin' : 'user'; + const role = resolveOidcRole(userInfo, isFirstUser); const randomPass = crypto.randomBytes(32).toString('hex'); const bcrypt = require('bcryptjs'); const hash = bcrypt.hashSync(randomPass, 10);