This repository contains modifications to the OpenID Connect Generic WordPress plugin to enhance Keycloak integration with advanced role mapping and synchronization features.
This project is licensed under GPL-2.0-or-later, in compliance with the original OpenID Connect Generic plugin license.
openid-connect-generic/- The base OpenID Connect Generic plugin (v3.10.0)mu-plugins/oidc-keycloak-custom.php- Must-Use plugin that extends the base plugin with Keycloak-specific features
The base plugin (openid-connect-generic/) is included unmodified from version 3.10.0. It serves as a dependency for the customization plugin.
MODIFIED: October 23, 2024
This customization plugin adds extensive Keycloak-specific functionality. Below is a detailed comparison of what changed from the original implementation.
Original Code:
if ( ! empty( $user_claim['user-realm-role'] ) ) {
foreach ( $user_claim['user-realm-role'] as $idp_role ) {Modified Code:
if ( ! empty( $user_claim['groups'] ) ) {
foreach ( $user_claim['groups'] as $idp_role ) {Why: Changed the expected claim key from user-realm-role to groups to align with Keycloak's standard group claims structure. This makes the integration more compatible with typical Keycloak configurations.
Impact:
- Affects:
oidc_keycloak_user_creation_test()andoidc_keycloak_map_user_role()functions - Better compatibility with Keycloak's default token structure
Original Code:
foreach ( $user_claim['user-realm-role'] as $idp_role ) {
foreach ( $roles as $role_id => $role_name ) {
if ( ! empty( $settings[ 'oidc_idp_' . strtolower( $role_name ) . '_roles' ] ) ) {
if ( in_array( $idp_role, explode( ';', $settings[ 'oidc_idp_' . strtolower( $role_name ) . '_roles' ] ) ) ) {
$user->add_role( $role_id ); // Only adds roles, never removes
$role_count++;
}
}
}
}Modified Code:
foreach ( $user_claim['groups'] as $idp_role ) {
foreach ( $roles as $role_id => $role_name ) {
if ( ! empty( $settings[ 'oidc_idp_' . strtolower( $role_name ) . '_roles' ] ) ) {
$mapped_roles = explode( ';', $settings[ 'oidc_idp_' . strtolower( $role_name ) . '_roles' ] );
if ( in_array( $idp_role, $mapped_roles ) ) {
// SECURITY: Use set_role() for first role to remove all existing roles
// This ensures full sync with Keycloak - roles removed in Keycloak are removed in WordPress
if ( $role_count === 0 ) {
$user->set_role( $role_id ); // Removes ALL existing roles first
} else {
$user->add_role( $role_id ); // Add additional roles
}
$role_count++;
}
}
}
}Why: The original implementation only added roles but never removed them. This created a security issue where:
- Users kept accumulating roles over time
- Roles removed in Keycloak were not removed in WordPress
- No true role synchronization occurred
Security Impact:
- CRITICAL SECURITY FIX: Ensures WordPress roles fully synchronize with Keycloak
- Prevents privilege escalation from accumulated roles
- First mapped role uses
set_role()to clear all existing roles - Subsequent roles use
add_role()to support multiple roles - If user has no matching roles in Keycloak, they are stripped of all WordPress roles
Original Code:
if ( intval( $role_count ) == 0 && ! empty( $settings['default_user_role'] ) ) {
if ( boolval( $settings['default_user_role'] ) ) {
$user->set_role( $settings['default_user_role'] );
}
}Modified Code:
if ( intval( $role_count ) == 0 ) {
if ( ! empty( $settings['default_user_role'] ) ) {
if ( boolval( $settings['default_user_role'] ) ) {
$user->set_role( $settings['default_user_role'] );
}
} else {
// SECURITY: If no roles match and no default, remove all roles
$user->set_role( '' );
}
}Why: Added explicit handling for the case where no roles match and no default is configured. The system now explicitly removes all roles rather than leaving the user with whatever they had before.
Security Impact:
- Prevents orphaned permissions when role mappings are removed
- Ensures explicit permission model (deny by default)
- No ambiguity in user access rights
What Was Added:
// Debug function 1: Log user claim data
function oidc_keycloak_debug_user_claim( $user, $user_claim ) {
error_log( "=== OIDC DEBUG: User Claim Data ===" );
error_log( print_r( $user_claim, true ) );
// ... logs role mapping settings
}
// Debug function 2: Track role mapping process
function oidc_keycloak_map_user_role( $user, $user_claim ) {
error_log( "=== OIDC DEBUG: Starting role mapping for user {$user->user_login} ===" );
// ... extensive logging throughout the role assignment process
}
// Debug function 3: Debug logout redirect URLs
function oidc_keycloak_debug_logout( $redirect_url, $requested_redirect_to, $user ) {
error_log( "=== OIDC DEBUG: Logout redirect ===" );
error_log( "OIDC DEBUG: redirect_url = " . $redirect_url );
// ... logs user info and OIDC token status
}
// Debug function 4: Debug FINAL logout redirect after OIDC plugin processing
function oidc_keycloak_debug_final_logout( $redirect_url, $requested_redirect_to, $user ) {
error_log( "=== OIDC DEBUG: FINAL logout redirect (after OIDC plugin) ===" );
// ... logs parsed URL components and validates redirect
}Why: The original plugin had no debug logging, making troubleshooting extremely difficult. Added comprehensive logging for:
- User claim data from Keycloak
- Role mapping configuration
- Role assignment decisions
- Logout redirect flow (priority 5 and priority 100)
Impact:
- Dramatically easier troubleshooting
- Visibility into Keycloak token contents
- Track role assignment logic
- Debug logout redirect issues (including URL length and parsing)
Added Hooks:
// Log user claims on user creation
add_action( 'openid-connect-generic-user-create', 'oidc_keycloak_debug_user_claim', 5, 2 );
// Log user claims on user update
add_action( 'openid-connect-generic-update-user-using-current-claim', 'oidc_keycloak_debug_user_claim', 5, 2 );
// Debug logout at priority 5 (before OIDC plugin)
add_filter( 'logout_redirect', 'oidc_keycloak_debug_logout', 5, 3 );
// Debug logout at priority 100 (after OIDC plugin at 99)
add_filter( 'logout_redirect', 'oidc_keycloak_debug_final_logout', 100, 3 );Why: Provides visibility at multiple points in the authentication lifecycle, including before and after the base OIDC plugin processes logout redirects.
All features from the original customization plugin are preserved:
- Customizable Login Button Text - Admin setting to customize SSO button text
- IDP Role Mapping - Map Keycloak groups/roles to WordPress roles (semicolon-separated)
- Required Role Enforcement - Optionally require valid role mapping for user creation
- Default Role Assignment - Assign default role when no IDP role matches
- Automatic Role Synchronization - Sync WordPress roles with Keycloak on every login (NEW BEHAVIOR)
- Role Removal on Logout - Remove unmapped roles (NEW BEHAVIOR)
- Comprehensive Debug Logging - Full visibility into authentication flow (NEW)
- Logout Redirect Debugging - Track logout URL transformations (NEW)
-
Install the base plugin:
cp -r openid-connect-generic /path/to/wordpress/wp-content/plugins/
-
Install the customization as a must-use plugin:
cp mu-plugins/oidc-keycloak-custom.php /path/to/wordpress/wp-content/mu-plugins/
-
Activate the base plugin via WordPress admin (Dashboard → Plugins)
-
MU-plugin is automatically loaded (cannot be deactivated)
If using a Dockerized WordPress setup:
volumes:
- ./mu-plugins:/var/www/html/wp-content/mu-plugins:ro
- ./openid-connect-generic:/var/www/html/wp-content/plugins/openid-connect-generic:roNavigate to Settings → OpenID Connect Client and configure:
- Client ID: Your Keycloak client ID
- Client Secret: Your Keycloak client secret
- Scope:
openid email profile groups(includegroupsclaim) - Login Endpoint:
https://your-keycloak/realms/{realm}/protocol/openid-connect/auth - Token Endpoint:
https://your-keycloak/realms/{realm}/protocol/openid-connect/token - Userinfo Endpoint:
https://your-keycloak/realms/{realm}/protocol/openid-connect/userinfo
In your Keycloak client settings:
-
Add Groups to Token: Create a client scope or mapper to include groups in the token
- Mapper Type:
Group Membership - Token Claim Name:
groups - Add to ID token: ✓
- Add to userinfo: ✓
- Mapper Type:
-
Create Groups/Roles in Keycloak matching your WordPress roles
The MU-plugin adds these settings to the OpenID Connect settings page:
Client Settings Section:
- Login Button Text: Customize the SSO button text (e.g., "Login with Keycloak")
User Settings Section:
- Valid IDP User Role Required: Check to prevent user creation without mapped role
- Default New User Role: Fallback role when no IDP role matches
- IDP Role Mappings: For each WordPress role (Administrator, Editor, etc.):
- Enter semicolon-separated list of Keycloak group names
- Example:
admin;administrators;superusers
Keycloak Groups:
wordpress-adminwordpress-editorwordpress-author
WordPress Plugin Configuration:
- IDP Role for WordPress Administrators:
wordpress-admin - IDP Role for WordPress Editors:
wordpress-editor - IDP Role for WordPress Authors:
wordpress-author - Default New User Role: Subscriber
Behavior:
- User in
wordpress-admingroup → WordPress Administrator role - User in
wordpress-editorgroup → WordPress Editor role - User in multiple groups → Gets multiple WordPress roles
- User in no mapped groups → Gets Subscriber role (default)
- User in no mapped groups (no default set) → All roles removed
-
Full Role Synchronization: WordPress roles now fully mirror Keycloak state
- Roles removed in Keycloak are immediately removed in WordPress
- Prevents privilege escalation from orphaned roles
- Uses
set_role()for first role to clear existing permissions
-
Explicit Permission Denial: When no roles match and no default is set, all roles are stripped
- Follows principle of least privilege
- No ambiguous permission states
-
Groups Claim: Uses standard
groupsclaim instead of non-standarduser-realm-role- Better alignment with OAuth2/OIDC standards
- Improved interoperability
- Always test role mappings in a staging environment first
- Enable debug logging initially to verify role assignment behavior
- Set a safe default role or enable "Required Role" to prevent unexpected access
- Monitor WordPress logs (
wp-content/debug.log) during initial deployment - Use HTTPS for all Keycloak endpoints in production
The customization plugin adds extensive logging when WP_DEBUG_LOG is enabled.
In wp-config.php:
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );Logs are written to: wp-content/debug.log
On User Login/Creation:
=== OIDC DEBUG: User Claim Data ===
Array (
[groups] => Array (
[0] => wordpress-admin
[1] => developers
)
[email] => user@example.com
...
)
=== OIDC DEBUG: Role Mapping Settings ===
WordPress Role 'Administrator' maps to IDP roles: wordpress-admin;site-admin
...
=== OIDC DEBUG: Starting role mapping for user john.doe ===
OIDC DEBUG: Found groups in user claim
OIDC DEBUG: Processing IDP role: wordpress-admin
OIDC DEBUG: Assigning WordPress role 'Administrator' (ID: administrator) to user
OIDC DEBUG: Setting role (removes all existing roles): administrator
OIDC DEBUG: Successfully assigned 1 role(s)
=== OIDC DEBUG: Finished role mapping ===
On Logout:
=== OIDC DEBUG: Logout redirect ===
OIDC DEBUG: redirect_url = http://example.com/wp-login.php?loggedout=true
OIDC DEBUG: has_oidc_token = yes
=== OIDC DEBUG: FINAL logout redirect (after OIDC plugin) ===
OIDC DEBUG FINAL: redirect_url = https://keycloak.example.com/realms/myrealm/protocol/openid-connect/logout?...
OIDC DEBUG FINAL: redirect_url length = 437 characters
| Aspect | Original Behavior | Modified Behavior |
|---|---|---|
| Role Addition | Only adds roles, never removes | First role removes all existing, then adds mapped roles |
| Role Removal | Roles never removed | Roles removed when not in Keycloak |
| Claim Key | user-realm-role |
groups (more standard) |
| No Role Match | User keeps existing roles | User loses all roles (or gets default) |
| Debugging | No logging | Extensive debug logging |
| Security Posture | Can accumulate privileges | Full synchronization with IDP |
| Logout Debugging | None | Tracks redirect transformations |
Check:
- Groups are included in Keycloak token (check debug logs)
- Role mapping settings match Keycloak group names exactly (case-sensitive)
groupsclaim is in the token (verify in logs)
Debug:
// In debug.log, look for:
"OIDC DEBUG: Found groups in user claim" // Should see this
"OIDC DEBUG: Processing IDP role: YOUR_GROUP_NAME" // Verify group namesCheck:
- Verify role mappings in plugin settings
- Check if "Valid IDP User Role Required" is enabled
- Verify default role setting
Solution: Roles sync on every login. Have user log out and log back in.
This was the bug in the original code. The modified version fixes this by using set_role() for the first role assignment, which clears all existing roles before assigning new ones.
Check debug logs for:
OIDC DEBUG: Logout redirect ===
OIDC DEBUG FINAL: FINAL logout redirect
Compare the URLs to identify where transformation occurs.
- Original Plugin: OpenID Connect Generic by Jonathan Daggerhart
- Customizations: Modified for enhanced Keycloak integration with security improvements
- License: GPL-2.0-or-later (same as original plugin)
Security Enhancements:
- Changed user claim key from
user-realm-roletogroupsfor better Keycloak compatibility - Implemented full role synchronization using
set_role()for first role - Added explicit role removal when no mappings match and no default is set
- Fixed privilege escalation vulnerability from accumulated roles
New Features:
- Comprehensive debug logging for user claims and role mapping
- Logout redirect debugging (priority 5 and 100)
- Enhanced error visibility for troubleshooting
Behavioral Changes:
- WordPress roles now fully sync with Keycloak on every login
- Roles removed in Keycloak are immediately removed in WordPress
- No role match without default = all roles removed (secure by default)
Features:
- Customizable login button text
- IDP role to WordPress role mapping
- Required role enforcement
- Default role assignment
- Basic role mapping on login/creation
This is a GPL-licensed project. Contributions, bug reports, and suggestions are welcome.
When reporting issues, please include:
- WordPress version
- Keycloak version
- Relevant debug log output
- Steps to reproduce
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
For support with:
- Base plugin issues: See OpenID Connect Generic documentation
- Customization issues: Check debug logs and troubleshooting section above
- Keycloak configuration: See Keycloak documentation