Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,46 @@ ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS=false
# Default scopes provide access to user profiles and group memberships
OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All

#===============================================#
# OIDC Group Synchronization (Keycloak, etc.) #
#===============================================#

# Enable automatic group synchronization from JWT token claims
# Works with any OIDC provider (Keycloak, Auth0, Okta, etc.)
# Requires OPENID_REUSE_TOKENS=true to be enabled
OPENID_SYNC_GROUPS_FROM_TOKEN=false

# Path to groups/roles claim in JWT token using dot notation
# Examples:
# - Keycloak realm roles: realm_access.roles
# - Keycloak client roles: resource_access.librechat.roles
# - Auth0 groups: https://your-domain.auth0.com/groups
# - Generic groups: groups
# Default: realm_access.roles
OPENID_GROUPS_CLAIM_PATH=realm_access.roles

# Which token to extract groups from: 'access' or 'id'
# access = access_token (recommended for most providers)
# id = id_token (use if groups are only in ID token)
# Default: access
OPENID_GROUPS_TOKEN_KIND=access

# Source identifier for synced groups in database
# This allows distinguishing between different OIDC providers
# Default: oidc
# Examples: keycloak, auth0, okta, google
OPENID_GROUP_SOURCE=oidc

# Exclude specific groups/roles from being synced
# Comma-separated list of exact role names (case-insensitive) or regex patterns (prefix with 'regex:')
# Use this to filter out system roles, default roles, or authentication roles
# Examples:
# - Exact matches: default-roles-mediawan,manage-account,view-profile,offline_access
# - Regex pattern: regex:^default-.*,regex:.*-account$
# - Mixed: default-roles-mediawan,regex:^uma_.*,offline_access
# Leave empty or commented to sync all groups
# OPENID_GROUPS_EXCLUDE_PATTERN=default-roles-mediawan,manage-account,view-profile,offline_access

# LDAP
LDAP_URL=
LDAP_BIND_DN=
Expand Down
10 changes: 9 additions & 1 deletion api/server/routes/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
const { ErrorTypes } = require('librechat-data-provider');
const { isEnabled, createSetBalanceConfig } = require('@librechat/api');
const { checkDomainAllowed, loginLimiter, logHeaders, checkBan } = require('~/server/middleware');
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
const {
syncUserEntraGroupMemberships,
syncUserOidcGroupsFromToken,
} = require('~/server/services/PermissionService');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { getAppConfig } = require('~/server/services/Config');
const { Balance } = require('~/db/models');
Expand Down Expand Up @@ -41,7 +44,12 @@
req.user.provider == 'openid' &&
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
) {
// Sync Entra ID groups from Microsoft Graph API (if enabled)
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);

Check failure

Code scanning / ESLint

Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Error

Delete ······
// Sync OIDC groups from JWT token claims (if enabled)
await syncUserOidcGroupsFromToken(req.user, req.user.tokenset);

Check failure

Code scanning / ESLint

Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Error

Delete ······
setOpenIDAuthTokens(req.user.tokenset, res, req.user._id.toString());
} else {
await setAuthTokens(req.user._id, res);
Expand Down
164 changes: 164 additions & 0 deletions api/server/services/PermissionService.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
getGroupMembers,
getGroupOwners,
} = require('~/server/services/GraphApiService');
const { extractGroupsFromToken } = require('~/utils/extractJwtClaims');
const {
findAccessibleResources: findAccessibleResourcesACL,
getEffectivePermissions: getEffectivePermissionsACL,
Expand Down Expand Up @@ -502,6 +503,168 @@
}
};

/**
* Sync user's OIDC groups from JWT token claims to LibreChat's Group database
* Extracts groups/roles from JWT token and syncs group memberships automatically on login
*

Check failure

Code scanning / ESLint

Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Error

Delete ·
* Supports any OpenID Connect provider (Keycloak, Auth0, Okta, etc.) by reading groups/roles
* from configurable JWT claim paths.
*

Check failure

Code scanning / ESLint

Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Error

Delete ·
* TODO: Future improvements:
* - Add transaction wrapping for full atomicity (currently relies on passed session)
* - Implement bulk query optimization for large group counts (>20 groups)
* - Add configurable group name transformation/mapping
* - Add role filtering to exclude system/default roles
* - Add orphaned group cleanup (groups with no members)
* - Add manual sync trigger via admin API
*

Check failure

Code scanning / ESLint

Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Error

Delete ·
* @param {Object} user - User object from authentication
* @param {string} user.idOnTheSource - User's external ID (oid or sub from token claims)
* @param {string} user.provider - Authentication provider ('openid')
* @param {Object} tokenset - OpenID Connect tokenset containing access_token and id_token
* @param {mongoose.ClientSession} [session] - Optional MongoDB session for transactions
* @returns {Promise<void>}
*/
const syncUserOidcGroupsFromToken = async (user, tokenset, session = null) => {
try {
// Check if feature is enabled
if (!isEnabled(process.env.OPENID_SYNC_GROUPS_FROM_TOKEN)) {
return;
}

// Validate user authentication
if (!user || user.provider !== 'openid' || !user.idOnTheSource) {
logger.debug('[PermissionService.syncUserOidcGroupsFromToken] User not eligible for OIDC group sync');

Check failure

Code scanning / ESLint

Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Error

Replace '[PermissionService.syncUserOidcGroupsFromToken]·User·not·eligible·for·OIDC·group·sync' with ⏎········'[PermissionService.syncUserOidcGroupsFromToken]·User·not·eligible·for·OIDC·group·sync',⏎······
return;
}

// Validate tokenset
if (!tokenset || typeof tokenset !== 'object') {
logger.warn('[PermissionService.syncUserOidcGroupsFromToken] Invalid tokenset provided');
return;
}

// Get configuration
const claimPath = process.env.OPENID_GROUPS_CLAIM_PATH || 'realm_access.roles';
const tokenKind = process.env.OPENID_GROUPS_TOKEN_KIND || 'access';
const groupSource = process.env.OPENID_GROUP_SOURCE || 'oidc';
const exclusionPattern = process.env.OPENID_GROUPS_EXCLUDE_PATTERN || null;

// Extract groups from token
const groupNames = extractGroupsFromToken(tokenset, claimPath, tokenKind, exclusionPattern);

if (!groupNames || groupNames.length === 0) {
logger.info(
`[PermissionService.syncUserOidcGroupsFromToken] No groups found for user ${user.email}`,
{
claimPath,
tokenKind,
},
);

// Remove user from all OIDC groups if they no longer have any groups
const sessionOptions = session ? { session } : {};
await Group.updateMany(
{
source: groupSource,
memberIds: user.idOnTheSource,
},
{ $pull: { memberIds: user.idOnTheSource } },
sessionOptions,
);

return;
}

logger.info(
`[PermissionService.syncUserOidcGroupsFromToken] Syncing ${groupNames.length} groups for user ${user.email}`,
{
groups: groupNames,
claimPath,
tokenKind,
source: groupSource,
},
);

const sessionOptions = session ? { session } : {};

// TODO: Performance optimization - Replace N+1 queries with bulk operations
// Current implementation queries each group individually (acceptable for <20 groups)
// Future improvement: Fetch all existing groups in single query, then bulk create/update
// Example:
// const existingGroups = await Group.find({ idOnTheSource: { $in: groupNames }, source: groupSource });
// const existingIds = new Set(existingGroups.map(g => g.idOnTheSource));
// const newGroups = groupNames.filter(name => !existingIds.has(name)).map(name => ({ ... }));
// await Group.insertMany(newGroups, sessionOptions);

// Create or update groups and add user to them
for (const groupName of groupNames) {
try {
// Check if group exists
let group = await Group.findOne({
idOnTheSource: groupName,
source: groupSource,
});

if (!group) {
// Create new group
group = await Group.create(
[
{
name: groupName,
idOnTheSource: groupName,
source: groupSource,
memberIds: [user.idOnTheSource],
},
],
sessionOptions,
);
logger.info(
`[PermissionService.syncUserOidcGroupsFromToken] Created new group: ${groupName}`,
);
} else {
// Add user to existing group if not already a member
if (!group.memberIds.includes(user.idOnTheSource)) {
await Group.updateOne(
{ _id: group._id },
{ $addToSet: { memberIds: user.idOnTheSource } },
sessionOptions,
);
logger.debug(
`[PermissionService.syncUserOidcGroupsFromToken] Added user to group: ${groupName}`,
);
}
}
} catch (error) {
logger.error(
`[PermissionService.syncUserOidcGroupsFromToken] Error processing group ${groupName}:`,
error,
);
}
}

// Remove user from groups they are no longer part of
await Group.updateMany(
{
source: groupSource,
memberIds: user.idOnTheSource,
idOnTheSource: { $nin: groupNames },
},
{ $pull: { memberIds: user.idOnTheSource } },
sessionOptions,
);

logger.info(
`[PermissionService.syncUserOidcGroupsFromToken] Successfully synced groups for user ${user.email}`,
);
} catch (error) {
logger.error(
`[PermissionService.syncUserOidcGroupsFromToken] Error syncing groups:`,
error,

Check failure

Code scanning / ESLint

Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Error

Replace ⏎······[PermissionService.syncUserOidcGroupsFromToken]·Error·syncing·groups:,⏎······error,⏎···· with ``[PermissionService.syncUserOidcGroupsFromToken]·Error·syncing·groups:,·error
);
}
};

/**
* Check if public has a specific permission on a resource
* @param {Object} params - Parameters for checking public permission
Expand Down Expand Up @@ -796,5 +959,6 @@
ensurePrincipalExists,
ensureGroupPrincipalExists,
syncUserEntraGroupMemberships,
syncUserOidcGroupsFromToken,
removeAllPermissions,
};
Loading
Loading