Skip to content

Commit 6d6014f

Browse files
committed
feat: Add group/role exclusion filter for OIDC group sync
Allow filtering out system/default roles from group synchronization: - Add shouldExcludeGroup() function with exact match and regex support - Support comma-separated exclusion patterns via OPENID_GROUPS_EXCLUDE_PATTERN - Case-insensitive exact matching for role names - Regex patterns with 'regex:' prefix (e.g., regex:^default-.*) - Update extractGroupsFromToken to accept and apply exclusion filter - Add comprehensive documentation in .env.example Use cases: - Exclude Keycloak default roles (default-roles-*, manage-account, etc.) - Exclude Auth0 system scopes (offline_access, openid, profile, email) - Exclude authentication-only roles that shouldn't become groups - Filter out UMA authorization roles (uma_authorization) Examples: OPENID_GROUPS_EXCLUDE_PATTERN=default-roles-mediawan,manage-account,offline_access OPENID_GROUPS_EXCLUDE_PATTERN=regex:^default-.*,regex:^manage-.*,offline_access Tested with Keycloak 26.x - successfully filters out system roles
1 parent 7c4baae commit 6d6014f

File tree

3 files changed

+68
-3
lines changed

3 files changed

+68
-3
lines changed

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,16 @@ OPENID_GROUPS_TOKEN_KIND=access
573573
# Examples: keycloak, auth0, okta, google
574574
OPENID_GROUP_SOURCE=oidc
575575

576+
# Exclude specific groups/roles from being synced
577+
# Comma-separated list of exact role names (case-insensitive) or regex patterns (prefix with 'regex:')
578+
# Use this to filter out system roles, default roles, or authentication roles
579+
# Examples:
580+
# - Exact matches: default-roles-mediawan,manage-account,view-profile,offline_access
581+
# - Regex pattern: regex:^default-.*,regex:.*-account$
582+
# - Mixed: default-roles-mediawan,regex:^uma_.*,offline_access
583+
# Leave empty or commented to sync all groups
584+
# OPENID_GROUPS_EXCLUDE_PATTERN=default-roles-mediawan,manage-account,view-profile,offline_access
585+
576586
# LDAP
577587
LDAP_URL=
578588
LDAP_BIND_DN=

api/server/services/PermissionService.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,9 +548,10 @@ const syncUserOidcGroupsFromToken = async (user, tokenset, session = null) => {
548548
const claimPath = process.env.OPENID_GROUPS_CLAIM_PATH || 'realm_access.roles';
549549
const tokenKind = process.env.OPENID_GROUPS_TOKEN_KIND || 'access';
550550
const groupSource = process.env.OPENID_GROUP_SOURCE || 'oidc';
551+
const exclusionPattern = process.env.OPENID_GROUPS_EXCLUDE_PATTERN || null;
551552

552553
// Extract groups from token
553-
const groupNames = extractGroupsFromToken(tokenset, claimPath, tokenKind);
554+
const groupNames = extractGroupsFromToken(tokenset, claimPath, tokenKind, exclusionPattern);
554555

555556
if (!groupNames || groupNames.length === 0) {
556557
logger.info(

api/utils/extractJwtClaims.js

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,54 @@ function sanitizeGroupName(groupName) {
104104
return sanitized;
105105
}
106106

107+
/**
108+
* Checks if a group name should be excluded based on exclusion patterns.
109+
* Supports exact matches (case-insensitive) and regex patterns (prefix with 'regex:').
110+
* @param {string} groupName - The group name to check.
111+
* @param {string|null} exclusionPattern - Comma-separated list of exact names or regex patterns.
112+
* @returns {boolean} True if the group should be excluded.
113+
*/
114+
function shouldExcludeGroup(groupName, exclusionPattern) {
115+
if (!exclusionPattern || typeof exclusionPattern !== 'string') {
116+
return false;
117+
}
118+
119+
const patterns = exclusionPattern.split(',').map(p => p.trim()).filter(Boolean);
120+
121+
for (const pattern of patterns) {
122+
// Check if it's a regex pattern
123+
if (pattern.startsWith('regex:')) {
124+
try {
125+
const regexStr = pattern.substring(6); // Remove 'regex:' prefix
126+
const regex = new RegExp(regexStr, 'i'); // Case-insensitive
127+
if (regex.test(groupName)) {
128+
logger.debug(`[shouldExcludeGroup] Excluding '${groupName}' (matched regex: ${regexStr})`);
129+
return true;
130+
}
131+
} catch (error) {
132+
logger.warn(`[shouldExcludeGroup] Invalid regex pattern '${pattern}': ${error.message}`);
133+
}
134+
} else {
135+
// Exact match (case-insensitive)
136+
if (pattern.toLowerCase() === groupName.toLowerCase()) {
137+
logger.debug(`[shouldExcludeGroup] Excluding '${groupName}' (exact match: ${pattern})`);
138+
return true;
139+
}
140+
}
141+
}
142+
143+
return false;
144+
}
145+
107146
/**
108147
* Extracts groups from JWT token for OIDC group synchronization
109148
* @param {Object} tokenset - OpenID token set containing access_token and id_token
110149
* @param {string} claimPath - Dot-notation path to groups/roles claim
111150
* @param {string} tokenKind - Which token to extract from ('access' or 'id')
151+
* @param {string|null} exclusionPattern - Optional exclusion pattern for filtering groups
112152
* @returns {Array<string>} Array of sanitized group names
113153
*/
114-
function extractGroupsFromToken(tokenset, claimPath, tokenKind = 'access') {
154+
function extractGroupsFromToken(tokenset, claimPath, tokenKind = 'access', exclusionPattern = null) {
115155
try {
116156
if (!tokenset || typeof tokenset !== 'object') {
117157
logger.warn('[extractGroupsFromToken] Invalid tokenset provided');
@@ -137,8 +177,21 @@ function extractGroupsFromToken(tokenset, claimPath, tokenKind = 'access') {
137177
.map(sanitizeGroupName)
138178
.filter(name => name.length > 0);
139179

180+
// Apply exclusion filter
181+
const filteredGroups = exclusionPattern
182+
? sanitizedGroups.filter(g => !shouldExcludeGroup(g, exclusionPattern))
183+
: sanitizedGroups;
184+
140185
// Remove duplicates
141-
const uniqueGroups = [...new Set(sanitizedGroups)];
186+
const uniqueGroups = [...new Set(filteredGroups)];
187+
188+
const excludedCount = sanitizedGroups.length - uniqueGroups.length;
189+
if (excludedCount > 0) {
190+
logger.info(
191+
`[extractGroupsFromToken] Excluded ${excludedCount} groups based on exclusion pattern`,
192+
{ pattern: exclusionPattern },
193+
);
194+
}
142195

143196
logger.info(
144197
`[extractGroupsFromToken] Extracted ${uniqueGroups.length} unique groups from ${tokenKind} token`,
@@ -156,5 +209,6 @@ module.exports = {
156209
extractClaimFromToken,
157210
sanitizeGroupName,
158211
extractGroupsFromToken,
212+
shouldExcludeGroup,
159213
};
160214

0 commit comments

Comments
 (0)