👑 feat: Add OIDC Claim-Based Admin Role Assignment (#9170)

* feat: Add support for users to be admins when logging in using OpenID

* fix: Linting issues

* fix: whitespace

* chore: add unit tests for OIDC_ADMIN_ROLE

* refactor: Replace custom property retrieval function with lodash's get for improved readability and maintainability

* feat: Enhance OpenID role extraction and error handling in setupOpenId function

- Improved role validation to check for both array and string types.
- Added detailed error messages for missing or invalid role paths in tokens.
- Expanded unit tests to cover various scenarios for nested role extraction and error handling.

* fix: Improve error handling for role extraction in OpenID strategy

- Enhanced validation to check for invalid role types (array or string).
- Updated error messages for clarity when roles are missing or of incorrect type.
- Added unit tests to cover scenarios where roles return invalid types (object, number).

* feat: Implement user role demotion in OpenID strategy when admin role is absent from token

- Added logic to demote users from 'ADMIN' to 'USER' if the admin role is not present in the token.
- Enhanced logging to capture role changes for better traceability.
- Introduced unit tests to verify the demotion behavior and ensure correct handling when admin role environment variables are not configured.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
José Pedro Silva 2025-10-09 08:35:22 +01:00 committed by GitHub
parent ff027e8243
commit 6fa3db2969
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 539 additions and 11 deletions

View file

@ -1,4 +1,5 @@
const undici = require('undici');
const { get } = require('lodash');
const fetch = require('node-fetch');
const passport = require('passport');
const client = require('openid-client');
@ -329,6 +330,12 @@ async function setupOpenId() {
: 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata',
});
// Set of env variables that specify how to set if a user is an admin
// If not set, all users will be treated as regular users
const adminRole = process.env.OPENID_ADMIN_ROLE;
const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
const openidLogin = new CustomOpenIDStrategy(
{
config: openidConfig,
@ -386,20 +393,19 @@ async function setupOpenId() {
} else if (requiredRoleTokenKind === 'id') {
decodedToken = jwtDecode(tokenset.id_token);
}
const pathParts = requiredRoleParameterPath.split('.');
let found = true;
let roles = pathParts.reduce((o, key) => {
if (o === null || o === undefined || !(key in o)) {
found = false;
return [];
}
return o[key];
}, decodedToken);
if (!found) {
let roles = get(decodedToken, requiredRoleParameterPath);
if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) {
logger.error(
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
`[openidStrategy] Key '${requiredRoleParameterPath}' not found or invalid type in ${requiredRoleTokenKind} token!`,
);
const rolesList =
requiredRoles.length === 1
? `"${requiredRoles[0]}"`
: `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
return done(null, false, {
message: `You must have ${rolesList} role to log in.`,
});
}
if (!requiredRoles.some((role) => roles.includes(role))) {
@ -447,6 +453,50 @@ async function setupOpenId() {
}
}
if (adminRole && adminRoleParameterPath && adminRoleTokenKind) {
let adminRoleObject;
switch (adminRoleTokenKind) {
case 'access':
adminRoleObject = jwtDecode(tokenset.access_token);
break;
case 'id':
adminRoleObject = jwtDecode(tokenset.id_token);
break;
case 'userinfo':
adminRoleObject = userinfo;
break;
default:
logger.error(
`[openidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`,
);
return done(new Error('Invalid admin role token kind'));
}
const adminRoles = get(adminRoleObject, adminRoleParameterPath);
// Accept 3 types of values for the object extracted from adminRoleParameterPath:
// 1. A boolean value indicating if the user is an admin
// 2. A string with a single role name
// 3. An array of role names
if (
adminRoles &&
(adminRoles === true ||
adminRoles === adminRole ||
(Array.isArray(adminRoles) && adminRoles.includes(adminRole)))
) {
user.role = 'ADMIN';
logger.info(
`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`,
);
} else if (user.role === 'ADMIN') {
user.role = 'USER';
logger.info(
`[openidStrategy] User ${username} demoted from admin - role no longer present in token`,
);
}
}
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
/** @type {string | undefined} */
const imageUrl = userinfo.picture;