What this plugin solves
- One source of truth: users authenticate in Keycloak; WordPress trusts it.
- No local password drift: blocks local logins unless you deliberately bypass.
- Role hygiene: adds users to an existing child group (find-only; no realm mutations).
- Session correctness: silent health checks detect remote logout and act locally.
- True global logout: WP session destroyed, miniOrange tokens cleared, Keycloak end-session called, and every open tab logged out.
Access your blog dashboard seamlessly and securely using OAuth login powered by Keycloak. Simply click the button below to authenticate and enjoy a unified login experience across all your WordPress admin features.
Use this in your project :-
<a class="mdc-drawer-link"
href="https://www.wizbrand.com/tutorials/cotocus1208/?option=oauthredirect&app_name=keycloak&redirect_to=https%3A%2F%2Fwww.wizbrand.com%2Ftutorials%2Fcotocus1208%2F">
<i class="material-icons mdc-list-item__start-detail mdc-drawer-item-icon" aria-hidden="true">dashboard</i>
My Blog
</a>

1) File and activation
wp-content/mu-plugins/force-oidc-login.php
2) Logging: know what the plugin is doing
if (!function_exists('foidc_log')) {
function foidc_log($m, $c = []) {
$line = '[' . date('Y-m-d H:i:s') . '] [FOIDC] ' . $m . (empty($c) ? '' : ' | ' . json_encode($c, JSON_UNESCAPED_SLASHES)) . PHP_EOL;
@file_put_contents(WP_CONTENT_DIR . '/oidc-debug.log', $line, FILE_APPEND);
@error_log(trim($line));
}
}
wp-Config .php
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true); // logs to wp-content/debug.log
define('WP_DEBUG_DISPLAY', false);
@ini_set('log_errors', 1);
@ini_set('display_errors', 0);
Why
- Every complicated SSO flow eventually needs tracing. This writes readable entries to
wp-content/oidc-debug.logand your PHP error log.
3) Constants: your environment in one place
define('FOIDC_LOGIN_PATH', 'cotocus1208'); // hidden login path if using WPS Hide Login
define('FOIDC_KC_BASE', 'https://auth.holidaylandmark.com');
define('FOIDC_KC_REALM', 'wizbrand');
define('FOIDC_KC_CLIENT','wizbrand-wordpress');
define('FOIDC_KC_CLIENT_SECRET', '***'); // replace
define('FOIDC_KC_ADMIN_CLIENT_ID', 'wizbrand-admin-cli');
define('FOIDC_KC_ADMIN_CLIENT_SECRET', '***'); // replace
define('FOIDC_REDIRECT_ADMIN_ALWAYS', true);
define('FOIDC_BYPASS_MINUTES', 10);
if (!defined('FOIDC_POST_LOGOUT_REDIRECT')) define(
'FOIDC_POST_LOGOUT_REDIRECT',
add_query_arg('local','1', rtrim(foidc_login_base_url(), '/'))
);
define('KC_PARENT_GROUP', '/wizbrand-wordpress-roles');
define('KC_CHILD_CONTRIBUTOR', 'contributor');
Why
- The login path ensures the plugin generates correct URLs even if
wp-login.phpis hidden. - KC_ client IDs/secrets* drive token, introspection, group lookups, and end-session.
- Admin redirect enforces SSO on
/wp-admin. - Bypass cookie lets you reach the login form immediately after logout (otherwise SSO would instantly pull you back in).
- Group constants define where users should live in Keycloak.
4) Token priming: make miniOrange tokens accessible
function foidc_set_access_cookie($access) {
if (!$access) return;
setcookie('mo_oauth_access_token', $access, time()+300, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
setcookie('mo_access_token', $access, time()+300, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
foidc_log('prime: set mo_* access token cookies');
}
function foidc_prime_tokens_for($user_id) {
$access = null;
// 1) usermeta written by miniOrange
$raw = get_user_meta($user_id, 'mo_oauth_token_response', true);
if ($raw && is_string($raw)) {
$j = json_decode($raw, true);
if (!empty($j['access_token'])) $access = $j['access_token'];
}
// 2) miniOrange cookies (various names they use)
if (!$access) {
$cands = ['mo_oauth_access_token','mo_access_token','miniorange_oauth_access_token','miniorange_access_token'];
foreach ($cands as $ck) {
if (!empty($_COOKIE[$ck])) { $access = $_COOKIE[$ck]; break; }
}
}
// 3) plugin option (rare but some builds stash last response per user)
if (!$access) {
$apps = get_option('mo_oauth_apps_list');
if (is_array($apps)) {
foreach ($apps as $appName => $cfg) {
$maybe = get_user_meta($user_id, 'mo_'.$appName.'_token', true);
if (is_string($maybe)) {
$j2 = json_decode($maybe, true);
if (!empty($j2['access_token'])) { $access = $j2['access_token']; break; }
}
}
}
}
if ($access) {
foidc_set_access_cookie($access);
return true;
}
foidc_log('prime: nothing to prime (no usermeta/cookie/option)');
return false;
}
Why
- miniOrange stores tokens inconsistently (usermeta, cookies, option blobs). This function hunts for the access_token and re-seeds predictable cookies so later flows (like health checks) can work without a full redirect.
5) HTTP wrapper: uniform debug for API calls
if (!function_exists('foidc_http_call')) {
function foidc_http_call($method, $url, $args = []) {
$t0 = microtime(true);
$safe_args = $args;
if (!empty($safe_args['body']['client_secret'])) {
$safe_args['body']['client_secret'] = '***';
}
if (!empty($safe_args['headers']['Authorization'])) {
$safe_args['headers']['Authorization'] = '***';
}
foidc_log('HTTP-> ' . strtoupper($method) . ' ' . $url, ['args' => $safe_args]);
$resp = wp_remote_request($url, array_merge(['method' => $method, 'timeout' => 20], $args));
$dt = round((microtime(true) - $t0) * 1000);
if (is_wp_error($resp)) {
foidc_log('HTTP<- ERR', ['ms' => $dt, 'err' => $resp->get_error_message()]);
return $resp;
}
$code = wp_remote_retrieve_response_code($resp);
$body = wp_remote_retrieve_body($resp);
$trunc = $body !== '' ? mb_substr($body, 0, 400) : '';
foidc_log('HTTP<- ' . $code, ['ms' => $dt, 'body' => $trunc]);
return $resp;
}
}
6) Token introspection: trust but verify (with fallback)
if (!function_exists('foidc_kc_introspect_endpoint')) {
function foidc_kc_introspect_endpoint() {
$ep = rtrim(FOIDC_KC_BASE, '/') . '/realms/' . rawurlencode(FOIDC_KC_REALM)
. '/protocol/openid-connect/token/introspect';
foidc_log('INTROSPECT endpoint', ['url' => $ep]);
return $ep;
}
}
if (!function_exists('foidc_kc_introspect_token')) {
function foidc_kc_introspect_token($accessToken) {
if (!$accessToken) {
foidc_log('INTROSPECT skip: empty token');
return null;
}
$ep = foidc_kc_introspect_endpoint();
$cid = FOIDC_KC_CLIENT;
$csec = FOIDC_KC_CLIENT_SECRET;
// Try client_secret_post first
$resp = foidc_http_call('POST', $ep, [
'body' => [
'client_id' => $cid,
'client_secret' => $csec,
'token' => $accessToken,
],
]);
if (is_wp_error($resp)) return null;
$code = wp_remote_retrieve_response_code($resp);
$json = json_decode(wp_remote_retrieve_body($resp), true);
// Fallback to client_secret_basic on 400/401/403 or malformed body
if ($code === 400 || $code === 401 || $code === 403 || !is_array($json)) {
foidc_log('INTROSPECT fallback: client_secret_basic');
$basic = base64_encode($cid . ':' . $csec);
$resp2 = foidc_http_call('POST', $ep, [
'headers' => ['Authorization' => 'Basic ' . $basic],
'body' => ['token' => $accessToken],
]);
if (is_wp_error($resp2)) return null;
$code2 = wp_remote_retrieve_response_code($resp2);
$json2 = json_decode(wp_remote_retrieve_body($resp2), true);
if ($code2 !== 200 || !is_array($json2)) {
foidc_log('INTROSPECT fail', ['code' => $code2, 'body_present' => is_array($json2) ? 'yes' : 'no']);
return null;
}
$active = !empty($json2['active']);
foidc_log('INTROSPECT result', ['active' => $active ? 'true' : 'false']);
return $active;
}
$active = !empty($json['active']);
foidc_log('INTROSPECT result', ['active' => $active ? 'true' : 'false']);
return $active;
}
}
Why
- Some sites validate access tokens with Keycloak. Different KC configs sometimes reject
client_secret_post. We try POST, then fallback to Basic automatically. - Return values normalize to
true(active),false(inactive),null(error).

7) miniOrange helpers: build the clean OAuth redirect
function foidc_get_app_name() {
$apps = get_option('mo_oauth_apps_list');
if (is_array($apps) && $apps) foreach ($apps as $key => $_) return $key;
return 'keycloak';
}
function foidc_build_oauthredirect($redirect_to = '', $extra_params = []) {
$app = foidc_get_app_name();
$dest = $redirect_to ?: admin_url();
// base is /cotocus1208/ now
$base = rtrim(foidc_login_base_url(), '/');
$url = $base . '?option=oauthredirect'
. '&app_name=' . rawurlencode($app)
. '&redirect_url=' . rawurlencode($dest);
foreach ($extra_params as $k => $v) {
$url .= '&' . rawurlencode($k) . '=' . rawurlencode($v);
}
return $url;
}
8) Cookies & guards: avoid redirect storms and support local bypass
function foidc_guard_redirect_loop() {
if (!empty($_COOKIE['foidc_once'])) return true;
setcookie('foidc_once', '1', time()+5, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
return false;
}
function foidc_set_local_bypass_cookie() {
setcookie('foidc_local_bypass', '1', time() + (int)FOIDC_BYPASS_MINUTES * 60, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
}
9) Capture Keycloak SID; handle silent-probe results
add_action('init', function () {
if (!empty($_GET['session_state']) && (isset($_GET['code']) || isset($_GET['state']))) {
$sid = sanitize_text_field($_GET['session_state']);
setcookie('foidc_last_sid', $sid, time()+1800, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
foidc_log('Captured SID from auth redirect', ['sid' => $sid]);
}
});
add_action('init', function () {
if (isset($_GET['error']) && $_GET['error'] === 'login_required' && !empty($_COOKIE['foidc_probe_inflight'])) {
if (function_exists('foidc_log')) foidc_log('Probe result: login_required -> LOCAL LOGOUT');
wp_logout();
if (function_exists('foidc_clear_miniorange_cookies')) foidc_clear_miniorange_cookies();
nocache_headers();
setcookie('foidc_probe_inflight', '', time() - 3600, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
$to = rtrim(foidc_login_base_url(), '/') . '?reauth=1';
if (!headers_sent()) { wp_safe_redirect($to, 302); exit; }
<?php
echo '<meta http-equiv="refresh" content="0;url='.esc_attr($to).'>';
?> exit;
}
// Success case: clear inflight marker
if (!empty($_GET['session_state']) && (isset($_GET['code']) || isset($_GET['state']))) {
setcookie('foidc_probe_inflight', '', time() - 3600, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
}
});
Why
- During prompt=none checks, if Keycloak says
login_required, we know the user’s IdP session is gone. We immediately log out locally.

10) Keycloak endpoints & admin token
function foidc_kc_token_endpoint() {
return rtrim(FOIDC_KC_BASE, '/') . '/realms/' . rawurlencode(FOIDC_KC_REALM) . '/protocol/openid-connect/token';
}
function foidc_kc_admin_users_base() {
return rtrim(FOIDC_KC_BASE, '/') . '/admin/realms/' . rawurlencode(FOIDC_KC_REALM) . '/users';
}
function foidc_kc_groups_base() {
return rtrim(FOIDC_KC_BASE, '/') . '/admin/realms/' . rawurlencode(FOIDC_KC_REALM) . '/groups';
}
function foidc_kc_end_session_endpoint() {
return rtrim(FOIDC_KC_BASE, '/') . '/realms/' . rawurlencode(FOIDC_KC_REALM) . '/protocol/openid-connect/logout';
}
function foidc_kc_admin_token() {
static $cache = null;
if ($cache) return $cache;
$body = [
'grant_type' => 'client_credentials',
'client_id' => FOIDC_KC_ADMIN_CLIENT_ID,
'client_secret' => FOIDC_KC_ADMIN_CLIENT_SECRET,
];
$resp = foidc_http_call('POST', foidc_kc_token_endpoint(), ['body' => $body]);
if (is_wp_error($resp)) return null;
$code = wp_remote_retrieve_response_code($resp);
$json = json_decode(wp_remote_retrieve_body($resp), true);
if ($code !== 200 || empty($json['access_token'])) {
foidc_log('KC token bad', ['code' => $code, 'body' => $json]);
return null;
}
return $cache = $json['access_token'];
}
Why
- Centralized endpoints make maintenance easy.
- The admin token is cached per request and grants read/find/group-attach powers only.
11) Finding users & groups (find-only; never create)
function foidc_kc_find_user($identifier) {
$tok = foidc_kc_admin_token();
if (!$tok) return null;
$isEmail = (strpos($identifier, '@') !== false);
$query = $isEmail ? ['email' => $identifier, 'exact' => 'true'] : ['username' => $identifier, 'exact' => 'true'];
$url = foidc_kc_admin_users_base() . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
$resp = foidc_http_call('GET', $url, ['headers' => ['Authorization' => 'Bearer ' . $tok, 'Accept' => 'application/json']]);
if (is_wp_error($resp)) return null;
$code = wp_remote_retrieve_response_code($resp);
$json = json_decode(wp_remote_retrieve_body($resp), true);
if ($code === 200 && is_array($json) && !empty($json)) return $json[0];
return null;
}
function foidc_kc_user_exists($identifier) {
return (bool) foidc_kc_find_user($identifier);
}
/* ===== groups: listing & resolvers (FIND-ONLY) ===== */
function foidc_kc_user_group_paths($kcUserId) {
$tok = foidc_kc_admin_token();
if (!$tok) return [];
$url = foidc_kc_admin_users_base() . '/' . rawurlencode($kcUserId) . '/groups';
$resp = foidc_http_call('GET', $url, ['headers'=>['Authorization'=>'Bearer '.$tok, 'Accept'=>'application/json']]);
if (is_wp_error($resp)) return [];
$code = wp_remote_retrieve_response_code($resp);
$json = json_decode(wp_remote_retrieve_body($resp), true);
if ($code !== 200 || !is_array($json)) return [];
$paths = [];
foreach ($json as $g) if (!empty($g['path'])) $paths[] = strtolower($g['path']);
return $paths;
}
/* fetch all root groups with subGroups */
function foidc_kc_fetch_all_root_groups_full() {
$tok = foidc_kc_admin_token();
if (!$tok) return [];
$all = [];
$first = 0; $page = 200;
while (true) {
$url = rtrim(FOIDC_KC_BASE,'/').'/admin/realms/'.rawurlencode(FOIDC_KC_REALM)
. '/groups?first='.$first.'&max='.$page.'&briefRepresentation=false';
$resp = foidc_http_call('GET', $url, ['headers'=>['Authorization'=>'Bearer '.$tok, 'Accept'=>'application/json']]);
if (is_wp_error($resp)) break;
$code = wp_remote_retrieve_response_code($resp);
$json = json_decode(wp_remote_retrieve_body($resp), true);
if ($code !== 200 || !is_array($json)) { foidc_log('KC groups page bad', ['code'=>$code, 'first'=>$first]); break; }
if (!count($json)) break;
$all = array_merge($all, $json);
if (count($json) < $page) break;
$first += $page;
}
return $all;
}
/* recursive finder by full path */
function foidc_kc_find_group_id_by_path($fullPath) {
$target = strtolower($fullPath);
$roots = foidc_kc_fetch_all_root_groups_full();
if (!$roots) { foidc_log('KC find group recursive: no roots'); return null; }
$walk = function($groups) use (&$walk, $target) {
foreach ($groups as $g) {
$path = isset($g['path']) ? strtolower($g['path']) : '';
if ($path === $target) return $g['id'] ?? null;
if (!empty($g['subGroups']) && is_array($g['subGroups'])) {
$found = $walk($g['subGroups']);
if ($found) return $found;
}
}
return null;
};
$id = $walk($roots);
if ($id) { foidc_log('KC find group recursive: found', ['path'=>$fullPath, 'id'=>$id]); return $id; }
/* fallback: leaf search then exact compare */
$tok = foidc_kc_admin_token();
if (!$tok) return null;
$leaf = basename($fullPath);
$surl = rtrim(FOIDC_KC_BASE,'/').'/admin/realms/'.rawurlencode(FOIDC_KC_REALM).'/groups?search='.rawurlencode($leaf);
$sresp = foidc_http_call('GET', $surl, ['headers'=>['Authorization'=>'Bearer '.$tok, 'Accept'=>'application/json']]);
if (!is_wp_error($sresp)) {
$scode = wp_remote_retrieve_response_code($sresp);
$sjson = json_decode(wp_remote_retrieve_body($sresp), true);
if ($scode === 200 && is_array($sjson)) {
foreach ($sjson as $g) {
if (!empty($g['path']) && strtolower($g['path']) === $target) {
foidc_log('KC find group fallback: found', ['path'=>$fullPath, 'id'=>$g['id']]);
return $g['id'];
}
}
}
}
foidc_log('KC find group recursive: not found', ['path'=>$fullPath]);
return null;
}
/* list direct children of a parent */
function foidc_kc_list_children($parentId, $first = 0, $max = 200) {
$tok = foidc_kc_admin_token(); if (!$tok) return [];
$base = rtrim(FOIDC_KC_BASE,'/').'/admin/realms/'.rawurlencode(FOIDC_KC_REALM);
$out = [];
while (true) {
$url = $base.'/groups/'.rawurlencode($parentId).'/children?first='.$first.'&max='.$max;
$resp = foidc_http_call('GET', $url, ['headers'=>['Authorization'=>'Bearer '.$tok,'Accept'=>'application/json']]);
if (is_wp_error($resp)) break;
$code = wp_remote_retrieve_response_code($resp);
$json = json_decode(wp_remote_retrieve_body($resp), true);
if ($code !== 200 || !is_array($json) || !count($json)) break;
$out = array_merge($out, $json);
if (count($json) < $max) break;
$first += $max;
}
return $out;
}
/* resolve exact path WITHOUT creating anything; if recursive misses, check parent's children by name (trim/lower) */
function foidc_kc_resolve_group_path_no_create($fullPath) {
$fullPath = trim($fullPath);
if ($fullPath === '' || $fullPath[0] !== '/') return null;
// Try direct
$id = foidc_kc_find_group_id_by_path($fullPath);
if ($id) return $id;
// Fallback: resolve parent then match child by name or full path (normalized)
$parentPath = rtrim(dirname($fullPath), '/');
$leaf = basename($fullPath);
if ($parentPath === '' || $parentPath === '.') return null;
$parentId = foidc_kc_find_group_id_by_path($parentPath);
if (!$parentId) {
foidc_log('resolve no-create: parent path not found', ['parent'=>$parentPath]);
return null;
}
$wantPath = strtolower($fullPath);
$wantName = strtolower(trim($leaf));
foreach (foidc_kc_list_children($parentId) as $c) {
$nm = isset($c['name']) ? strtolower(trim($c['name'])) : '';
$p = isset($c['path']) ? strtolower(trim($c['path'])) : '';
if ($nm === $wantName || $p === $wantPath) {
foidc_log('resolve no-create: child matched under parent', ['child'=>$c['name'],'id'=>$c['id'] ?? '']);
return $c['id'] ?? null;
}
}
foidc_log('resolve no-create: child not found under parent', ['path'=>$fullPath]);
return null;
}
Why
- Users: resolve by exact email or username.
- Groups: we walk trees, then fallback search, then verify full path. We do not create groups. This keeps realm structure safe.
12) Add user to child group (only if it already exists)
function foidc_kc_add_user_to_group($kcUserId, $kcGroupId) {
$tok = foidc_kc_admin_token();
if (!$tok) return false;
$url = foidc_kc_admin_users_base() . '/' . rawurlencode($kcUserId) . '/groups/' . rawurlencode($kcGroupId);
$resp = foidc_http_call('PUT', $url, ['headers'=>['Authorization'=>'Bearer '.$tok]]);
if (is_wp_error($resp)) return false;
$code = wp_remote_retrieve_response_code($resp);
foidc_log('KC add to group done', ['code'=>$code, 'userId'=>$kcUserId, 'groupId'=>$kcGroupId]);
return ($code >= 200 && $code < 300);
}
/* ===== ensure baseline membership under EXISTING child (FIND ONLY) ===== */
function foidc_ensure_contributor_membership($identifier) {
$kcUser = foidc_kc_find_user($identifier);
if (!$kcUser || empty($kcUser['id'])) { foidc_log('ensure contrib: KC user not found', ['id'=>$identifier]); return false; }
$uid = $kcUser['id'];
$paths = foidc_kc_user_group_paths($uid); // lowercase paths
$parentPrefix = strtolower(rtrim(KC_PARENT_GROUP,'/')).'/';
foreach ($paths as $p) {
if (strpos($p, $parentPrefix) === 0) {
foidc_log('ensure contrib: already under parent', ['paths'=>$paths]);
return true;
}
}
$targetPath = rtrim(KC_PARENT_GROUP, '/').'/'.KC_CHILD_CONTRIBUTOR;
$gid = foidc_kc_resolve_group_path_no_create($targetPath); // FIND ONLY
if (!$gid) {
foidc_log('ensure contrib: target child NOT found (no-create mode)', ['path'=>$targetPath]);
return false;
}
$ok = foidc_kc_add_user_to_group($uid, $gid);
foidc_log('ensure contrib: added to EXISTING child?', ['ok'=>$ok ? 'yes' : 'no', 'path'=>$targetPath, 'gid'=>$gid]);
return $ok;
}
Why
- Ensures every authenticated user sits under your parent group, at least in
contributor(or your chosen child). If they’re already under the parent branch, no changes occur.
13) ID token hint & end-session URL builder
/* ===== ID token hint for front-channel logout ===== */
function foidc_guess_id_token() {
$candidates = ['mo_oauth_id_token', 'mo_id_token', 'id_token'];
foreach ($candidates as $ck) {
if (!empty($_COOKIE[$ck])) return $_COOKIE[$ck];
}
if (is_user_logged_in()) {
$u = wp_get_current_user();
$raw = get_user_meta($u->ID, 'mo_oauth_token_response', true);
if ($raw && is_string($raw)) {
$j = json_decode($raw, true);
if (!empty($j['id_token'])) return $j['id_token'];
}
}
return null;
}
function foidc_build_kc_logout_url($redirectTo = null) {
$redirect = $redirectTo ?: FOIDC_POST_LOGOUT_REDIRECT;
$params = ['post_logout_redirect_uri' => $redirect, 'client_id' => FOIDC_KC_CLIENT];
if ($hint = foidc_guess_id_token()) $params['id_token_hint'] = $hint;
return foidc_kc_end_session_endpoint() . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
}
function foidc_clear_miniorange_cookies() {
foreach (['mo_oauth_id_token','mo_oauth_access_token','mo_oauth_refresh_token','mo_id_token','mo_access_token','mo_refresh_token','foidc_id_token'] as $n)
if (isset($_COOKIE[$n])) setcookie($n, '', time()-3600, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
}
Why
id_token_hinthelps Keycloak close the right session quickly.- We wipe all miniOrange token cookies to avoid ghosts after logout.
14) Debug tool: inspect children of your parent group
add_action('init', function () {
if (!isset($_GET['foidc_dump_parent'])) return;
$parent = KC_PARENT_GROUP;
$pid = foidc_kc_find_group_id_by_path($parent);
if (!$pid) { foidc_log('dump parent: parent not found', ['parent'=>$parent]); header('Content-Type:text/plain'); echo "Parent not found\n"; exit; }
$kids = foidc_kc_list_children($pid);
$names = array_map(function($g){ return ['id'=>$g['id']??'', 'name'=>$g['name']??'', 'path'=>$g['path']??'']; }, $kids);
foidc_log('dump parent: children', ['parent'=>$parent, 'children'=>$names]);
header('Content-Type:text/plain'); echo "Dumped children to wp-content/oidc-debug.log\n"; exit;
});
Why
- Writes a clean list of child groups to the log so you can confirm structure and IDs.
15) Force SSO on /wp-admin
/* ---------------- Admin auto-redirect to SSO ---------------- */
add_action('init', function () {
if (!FOIDC_REDIRECT_ADMIN_ALWAYS) return;
if (!is_admin() || wp_doing_ajax() || is_user_logged_in()) return;
if (foidc_has_local_bypass()) {
$login = foidc_login_base_url();
foidc_log('Bypass active: /wp-admin → login', ['to' => $login]);
if (!headers_sent()) { wp_safe_redirect($login, 302); exit; }
return;
}
$uri = $_SERVER['REQUEST_URI'] ?? '';
if (foidc_guard_redirect_loop()) { foidc_log('Loop guard hit (admin)'); return; }
// If already on hidden login + oauthredirect, do nothing
$loginPath = defined('FOIDC_LOGIN_PATH') ? trim(FOIDC_LOGIN_PATH, '/') : '';
if ($loginPath && strpos($uri, $loginPath) !== false && strpos($uri, 'option=oauthredirect') !== false) return;
$oidc = foidc_build_oauthredirect(admin_url());
foidc_log('Admin without session → start SSO', ['to' => $oidc]);
if (!headers_sent()) { wp_safe_redirect($oidc, 302); exit; }
});
Why
- Eliminate the default WP login form admin entry. All roads lead to Keycloak.
16) Silent SSO probe (prompt=none)
add_action('init', function () {
if (!is_user_logged_in()) return;
$uri = $_SERVER['REQUEST_URI'] ?? '';
if (strpos($uri, 'wp-login.php') !== false) return;
if (isset($_GET['foidc_logout']) || isset($_GET['foidc_front_logout']) ||
(isset($_GET['option']) && $_GET['option'] === 'oauthredirect')) return;
if (!empty($_COOKIE['foidc_just_logged_in'])) return;
$last = isset($_COOKIE['foidc_last_silent_check']) ? intval($_COOKIE['foidc_last_silent_check']) : 0;
if (time() - $last < 15) return;
setcookie('foidc_last_silent_check', (string) time(), time()+300, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
if (function_exists('foidc_log')) foidc_log('Silent-check: probe-only (no token gating)');
foidc_start_silent_probe(); // sets foidc_probe_inflight and redirects with prompt=none
}, 2);
function foidc_start_silent_probe($redirect_to = null) {
$extra = [
'prompt' => 'none',
'foidc_probe' => '1',
];
$to = foidc_build_oauthredirect($redirect_to ?: admin_url(), $extra);
foidc_log('Silent-probe → oauthredirect (prompt=none)', ['to'=>$to]);
setcookie('foidc_probe_inflight', '1', time()+120, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
if (!headers_sent()) { wp_safe_redirect($to, 302); exit; }
<?php
echo '<meta http-equiv="refresh" content="0;url='.esc_attr($to).'>';
?> exit;
}
Why
- Zero-UI session check. If Keycloak session died elsewhere, WP logs you out automatically
17) Login page guard & redirect after login
add_action('login_init', function () {
if (is_user_logged_in()) return;
$action = isset($_REQUEST['action']) ? sanitize_key($_REQUEST['action']) : '';
$has = isset($_GET['code']) || isset($_GET['state']) || isset($_GET['id_token']);
$is_or = (isset($_GET['option']) && $_GET['option'] === 'oauthredirect');
$allowed = ['logout','postpass','register','rp'];
foidc_log('login_init', [
'action' => $action,
'has_oidc' => $has ? 'yes' : 'no',
'oauthredirect' => $is_or ? 'yes' : 'no',
'uri' => $_SERVER['REQUEST_URI'] ?? ''
]);
if ($has || $is_or || in_array($action, $allowed, true)) return;
});
/* After login → always dashboard */
add_filter('login_redirect', function ($to, $req, $user) {
if (is_wp_error($user)) return $to;
$dst = admin_url();
foidc_log('login_redirect -> dashboard', ['final' => $dst]);
return $dst;
}, 10, 3);
18) Ensure miniOrange redirect target is sane
add_action('init', function () {
if (is_user_logged_in()) return;
$cur = get_option('mo_oauth_redirect_url');
$want = admin_url();
if (!$cur || $cur === home_url()) {
update_option('mo_oauth_redirect_url', $want);
foidc_log('Set mo_oauth_redirect_url', ['value' => $want]);
}
});
Why
- Some miniOrange setups default to
home_url()which can cause weird loops. We normalize towp-admin.
19) Authenticate filter: Keycloak users only
/* Authenticate hook: if user exists in KC, ensure child membership and kick SSO; else block local login */
add_filter('authenticate', function ($user, $username, $password) {
if (defined('DOING_AJAX') && DOING_AJAX) return $user;
if (empty($username)) return $user;
if (foidc_has_local_bypass()) { foidc_log('Local bypass cookie present → allow WP auth'); return $user; }
if (isset($_GET['option']) && $_GET['option'] === 'oauthredirect') return $user;
$exists = foidc_kc_user_exists($username);
if ($exists) {
foidc_ensure_contributor_membership($username);
if (foidc_guard_redirect_loop()) { foidc_log('Loop guard hit (authenticate)'); return $user; }
$extra = ['login_hint' => $username, 'prompt' => 'login'];
$to = foidc_build_oauthredirect(admin_url(), $extra);
foidc_log('User found in KC → redirect to SSO', ['username' => $username, 'to' => $to]);
if (!headers_sent()) { wp_safe_redirect($to, 302); exit; }
return null;
}
foidc_log('User NOT in KC → block local login', ['username' => $username]);
return new WP_Error('kc_only', __('Please sign in via Single Sign-On. Your account must exist in Keycloak.'));
}, 1, 3);
Why
- Blocks random local passwords, reducing attack surface.
- Sends valid users through OIDC every time to keep sessions aligned.
20) After SSO return: ensure groups + seed tokens
add_action('wp_login', function($user_login, $user){
$identifier = $user->user_email ?: $user->user_login;
foidc_ensure_contributor_membership($identifier);
$primed = foidc_prime_tokens_for($user->ID);
setcookie('foidc_just_logged_in', '1', time()+20, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
}, 10, 2);
21) Login base URL helpers
function foidc_login_base_url() {
if (defined('FOIDC_LOGIN_PATH') && FOIDC_LOGIN_PATH) {
return home_url('/' . trim(FOIDC_LOGIN_PATH, '/') . '/');
}
$base = strtok(wp_login_url(), '?');
return trailingslashit($base);
}
function foidc_login_url_build(array $args = []) {
$url = foidc_login_base_url();
return $args ? add_query_arg($args, $url) : $url;
}
22) Silent probe throttle
function foidc_should_probe_now() {
$last = isset($_COOKIE['foidc_last_probe']) ? intval($_COOKIE['foidc_last_probe']) : 0;
if (time() - $last < 60) return false;
setcookie('foidc_last_probe', (string) time(), time()+300, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
return true;
}
Why
- Avoids hammering Keycloak. We already throttle the main check; this is a second safety.
23) Step-by-step testing plan
- /wp-admin redirect
- Logged out → visit
/wp-admin→ redirect to Keycloak → back to Dashboard.
- Logged out → visit
- Local login blocked
- On hidden login page, type a non-Keycloak user → get “Please sign in via Single Sign-On…”.
- Group ensure
- Remove user from child group in Keycloak → login → plugin adds back under the parent branch.
- Silent probe
- Log out from Keycloak elsewhere while a WP tab is open → tab should soon log out and go to reauth.
- Global logout
- Click “Log Out” → WP session gone, miniOrange cookies cleared, other tabs also kicked, then Keycloak end-session returns you to your redirect.
- Inspect logs
- Check
wp-content/oidc-debug.logfor readable traces of every step.
- Check
24) Full code:-
<?php
/*
Plugin Name: Force OIDC Login (miniOrange clean flow) + Nested Groups + Global SLO Sync
Version: 3.4-silent-probe+kc-only
Author: You
*/
/* ------------------ logging ------------------ */
if (!function_exists('foidc_log')) {
function foidc_log($m, $c = []) {
$line = '[' . date('Y-m-d H:i:s') . '] [FOIDC] ' . $m . (empty($c) ? '' : ' | ' . json_encode($c, JSON_UNESCAPED_SLASHES)) . PHP_EOL;
@file_put_contents(WP_CONTENT_DIR . '/oidc-debug.log', $line, FILE_APPEND);
@error_log(trim($line));
}
}
/* ========= KEYCLOAK CONFIG (PROD) ========= */
/* NOTE: Use RP client (wizbrand-wordpress) for login + introspection */
if (!defined('FOIDC_LOGIN_PATH')) define('FOIDC_LOGIN_PATH', 'login.php');
if (!defined('FOIDC_KC_BASE')) define('FOIDC_KC_BASE', 'http://localhost:4000');
if (!defined('FOIDC_KC_REALM')) define('FOIDC_KC_REALM', 'master');
if (!defined('FOIDC_KC_CLIENT')) define('FOIDC_KC_CLIENT','wordpress');
if (!defined('FOIDC_KC_CLIENT_SECRET')) define('FOIDC_KC_CLIENT_SECRET', '****');
if (!defined('FOIDC_KC_ADMIN_CLIENT_ID')) define('FOIDC_KC_ADMIN_CLIENT_ID', 'wizbrand');
if (!defined('FOIDC_KC_ADMIN_CLIENT_SECRET'))define('FOIDC_KC_ADMIN_CLIENT_SECRET', '****');
/* Behavior */
if (!defined('FOIDC_REDIRECT_ADMIN_ALWAYS')) define('FOIDC_REDIRECT_ADMIN_ALWAYS', true);
if (!defined('FOIDC_BYPASS_MINUTES')) define('FOIDC_BYPASS_MINUTES', 10);
if (!defined('FOIDC_POST_LOGOUT_REDIRECT')) define('FOIDC_POST_LOGOUT_REDIRECT', add_query_arg('local','1', wp_login_url()));
/* ===== Nested group constants ===== */
if (!defined('KC_PARENT_GROUP')) define('KC_PARENT_GROUP', '/wizbrand-wordpress-roles');
if (!defined('KC_CHILD_CONTRIBUTOR')) define('KC_CHILD_CONTRIBUTOR', 'contributor');
/* ---------- Token priming helpers (robust) ---------- */
function foidc_set_access_cookie($access) {
if (!$access) return;
setcookie('mo_oauth_access_token', $access, time()+300, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
setcookie('mo_access_token', $access, time()+300, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
foidc_log('prime: set mo_* access token cookies');
}
/* Read tokens from all likely places (usermeta, known cookies, MO option blob) */
function foidc_prime_tokens_for($user_id) {
$access = null;
// 1) usermeta written by miniOrange
$raw = get_user_meta($user_id, 'mo_oauth_token_response', true);
if ($raw && is_string($raw)) {
$j = json_decode($raw, true);
if (!empty($j['access_token'])) $access = $j['access_token'];
}
// 2) miniOrange cookies (various names they use)
if (!$access) {
$cands = ['mo_oauth_access_token','mo_access_token','miniorange_oauth_access_token','miniorange_access_token'];
foreach ($cands as $ck) {
if (!empty($_COOKIE[$ck])) { $access = $_COOKIE[$ck]; break; }
}
}
// 3) plugin option (rare but some builds stash last response per user)
if (!$access) {
$apps = get_option('mo_oauth_apps_list');
if (is_array($apps)) {
foreach ($apps as $appName => $cfg) {
$maybe = get_user_meta($user_id, 'mo_'.$appName.'_token', true);
if (is_string($maybe)) {
$j2 = json_decode($maybe, true);
if (!empty($j2['access_token'])) { $access = $j2['access_token']; break; }
}
}
}
}
if ($access) {
foidc_set_access_cookie($access);
return true;
}
foidc_log('prime: nothing to prime (no usermeta/cookie/option)');
return false;
}
/* ---------------- HTTP DEBUG WRAPPER ---------------- */
if (!function_exists('foidc_http_call')) {
function foidc_http_call($method, $url, $args = []) {
$t0 = microtime(true);
$safe_args = $args;
if (!empty($safe_args['body']['client_secret'])) {
$safe_args['body']['client_secret'] = '***';
}
if (!empty($safe_args['headers']['Authorization'])) {
$safe_args['headers']['Authorization'] = '***';
}
foidc_log('HTTP-> ' . strtoupper($method) . ' ' . $url, ['args' => $safe_args]);
$resp = wp_remote_request($url, array_merge(['method' => $method, 'timeout' => 20], $args));
$dt = round((microtime(true) - $t0) * 1000);
if (is_wp_error($resp)) {
foidc_log('HTTP<- ERR', ['ms' => $dt, 'err' => $resp->get_error_message()]);
return $resp;
}
$code = wp_remote_retrieve_response_code($resp);
$body = wp_remote_retrieve_body($resp);
$trunc = $body !== '' ? mb_substr($body, 0, 400) : '';
foidc_log('HTTP<- ' . $code, ['ms' => $dt, 'body' => $trunc]);
return $resp;
}
}
/* ---------------- Introspection (use RP client) ---------------- */
if (!function_exists('foidc_kc_introspect_endpoint')) {
function foidc_kc_introspect_endpoint() {
$ep = rtrim(FOIDC_KC_BASE, '/') . '/realms/' . rawurlencode(FOIDC_KC_REALM)
. '/protocol/openid-connect/token/introspect';
foidc_log('INTROSPECT endpoint', ['url' => $ep]);
return $ep;
}
}
if (!function_exists('foidc_kc_introspect_token')) {
/**
* true => token active
* false => token inactive
* null => unknown/error
*/
function foidc_kc_introspect_token($accessToken) {
if (!$accessToken) {
foidc_log('INTROSPECT skip: empty token');
return null;
}
$ep = foidc_kc_introspect_endpoint();
$cid = FOIDC_KC_CLIENT;
$csec = FOIDC_KC_CLIENT_SECRET;
// Try client_secret_post first
$resp = foidc_http_call('POST', $ep, [
'body' => [
'client_id' => $cid,
'client_secret' => $csec,
'token' => $accessToken,
],
]);
if (is_wp_error($resp)) return null;
$code = wp_remote_retrieve_response_code($resp);
$json = json_decode(wp_remote_retrieve_body($resp), true);
// Fallback to client_secret_basic on 400/401/403 or malformed body
if ($code === 400 || $code === 401 || $code === 403 || !is_array($json)) {
foidc_log('INTROSPECT fallback: client_secret_basic');
$basic = base64_encode($cid . ':' . $csec);
$resp2 = foidc_http_call('POST', $ep, [
'headers' => ['Authorization' => 'Basic ' . $basic],
'body' => ['token' => $accessToken],
]);
if (is_wp_error($resp2)) return null;
$code2 = wp_remote_retrieve_response_code($resp2);
$json2 = json_decode(wp_remote_retrieve_body($resp2), true);
if ($code2 !== 200 || !is_array($json2)) {
foidc_log('INTROSPECT fail', ['code' => $code2, 'body_present' => is_array($json2) ? 'yes' : 'no']);
return null;
}
$active = !empty($json2['active']);
foidc_log('INTROSPECT result', ['active' => $active ? 'true' : 'false']);
return $active;
}
$active = !empty($json['active']);
foidc_log('INTROSPECT result', ['active' => $active ? 'true' : 'false']);
return $active;
}
}
/* ---------------- MiniOrange helpers ---------------- */
function foidc_get_app_name() {
$apps = get_option('mo_oauth_apps_list');
if (is_array($apps) && $apps) foreach ($apps as $key => $_) return $key;
return 'keycloak';
}
function foidc_build_oauthredirect($redirect_to = '', $extra_params = []) {
$app = foidc_get_app_name();
$dest = $redirect_to ?: admin_url();
$url = site_url('/wp-login.php?option=oauthredirect'
. '&app_name=' . rawurlencode($app)
. '&redirect_url=' . rawurlencode($dest));
foreach ($extra_params as $k => $v) $url .= '&' . rawurlencode($k) . '=' . rawurlencode($v);
return $url;
}
/* ---------------- Cookies & Guards ---------------- */
function foidc_guard_redirect_loop() {
if (!empty($_COOKIE['foidc_once'])) return true;
setcookie('foidc_once', '1', time()+5, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
return false;
}
function foidc_set_local_bypass_cookie() {
setcookie('foidc_local_bypass', '1', time() + (int)FOIDC_BYPASS_MINUTES * 60, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
}
add_action('init', function () {
if (!empty($_GET['session_state']) && (isset($_GET['code']) || isset($_GET['state']))) {
$sid = sanitize_text_field($_GET['session_state']);
setcookie('foidc_last_sid', $sid, time()+1800, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
foidc_log('Captured SID from auth redirect', ['sid' => $sid]);
}
});
/* ----- Handle prompt=none results: logout ONLY on login_required ----- */
add_action('init', function () {
if (isset($_GET['error']) && $_GET['error'] === 'login_required' && !empty($_COOKIE['foidc_probe_inflight'])) {
if (function_exists('foidc_log')) foidc_log('Probe result: login_required -> LOCAL LOGOUT');
wp_logout();
if (function_exists('foidc_clear_miniorange_cookies')) foidc_clear_miniorange_cookies();
nocache_headers();
setcookie('foidc_probe_inflight', '', time() - 3600, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
$to = rtrim(foidc_login_base_url(), '/') . '?reauth=1';
if (!headers_sent()) { wp_safe_redirect($to, 302); exit; }
<?php
echo '<meta http-equiv="refresh" content="0;url='.esc_attr($to).'>';
?> exit;
}
// Success case: clear inflight marker
if (!empty($_GET['session_state']) && (isset($_GET['code']) || isset($_GET['state']))) {
setcookie('foidc_probe_inflight', '', time() - 3600, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
}
});
function foidc_has_local_bypass() { return !empty($_COOKIE['foidc_local_bypass']); }
/* ---------------- Keycloak endpoints ---------------- */
function foidc_kc_token_endpoint() {
return rtrim(FOIDC_KC_BASE, '/') . '/realms/' . rawurlencode(FOIDC_KC_REALM) . '/protocol/openid-connect/token';
}
function foidc_kc_admin_users_base() {
return rtrim(FOIDC_KC_BASE, '/') . '/admin/realms/' . rawurlencode(FOIDC_KC_REALM) . '/users';
}
function foidc_kc_groups_base() {
return rtrim(FOIDC_KC_BASE, '/') . '/admin/realms/' . rawurlencode(FOIDC_KC_REALM) . '/groups';
}
function foidc_kc_end_session_endpoint() {
return rtrim(FOIDC_KC_BASE, '/') . '/realms/' . rawurlencode(FOIDC_KC_REALM) . '/protocol/openid-connect/logout';
}
/* ---------------- Admin token ---------------- */
function foidc_kc_admin_token() {
static $cache = null;
if ($cache) return $cache;
$body = [
'grant_type' => 'client_credentials',
'client_id' => FOIDC_KC_ADMIN_CLIENT_ID,
'client_secret' => FOIDC_KC_ADMIN_CLIENT_SECRET,
];
$resp = foidc_http_call('POST', foidc_kc_token_endpoint(), ['body' => $body]);
if (is_wp_error($resp)) return null;
$code = wp_remote_retrieve_response_code($resp);
$json = json_decode(wp_remote_retrieve_body($resp), true);
if ($code !== 200 || empty($json['access_token'])) {
foidc_log('KC token bad', ['code' => $code, 'body' => $json]);
return null;
}
return $cache = $json['access_token'];
}
/* ---------------- User lookup ---------------- */
function foidc_kc_find_user($identifier) {
$tok = foidc_kc_admin_token();
if (!$tok) return null;
$isEmail = (strpos($identifier, '@') !== false);
$query = $isEmail ? ['email' => $identifier, 'exact' => 'true'] : ['username' => $identifier, 'exact' => 'true'];
$url = foidc_kc_admin_users_base() . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
$resp = foidc_http_call('GET', $url, ['headers' => ['Authorization' => 'Bearer ' . $tok, 'Accept' => 'application/json']]);
if (is_wp_error($resp)) return null;
$code = wp_remote_retrieve_response_code($resp);
$json = json_decode(wp_remote_retrieve_body($resp), true);
if ($code === 200 && is_array($json) && !empty($json)) return $json[0];
return null;
}
function foidc_kc_user_exists($identifier) {
return (bool) foidc_kc_find_user($identifier);
}
/* ===== groups: listing & resolvers (FIND-ONLY) ===== */
function foidc_kc_user_group_paths($kcUserId) {
$tok = foidc_kc_admin_token();
if (!$tok) return [];
$url = foidc_kc_admin_users_base() . '/' . rawurlencode($kcUserId) . '/groups';
$resp = foidc_http_call('GET', $url, ['headers'=>['Authorization'=>'Bearer '.$tok, 'Accept'=>'application/json']]);
if (is_wp_error($resp)) return [];
$code = wp_remote_retrieve_response_code($resp);
$json = json_decode(wp_remote_retrieve_body($resp), true);
if ($code !== 200 || !is_array($json)) return [];
$paths = [];
foreach ($json as $g) if (!empty($g['path'])) $paths[] = strtolower($g['path']);
return $paths;
}
/* fetch all root groups with subGroups */
function foidc_kc_fetch_all_root_groups_full() {
$tok = foidc_kc_admin_token();
if (!$tok) return [];
$all = [];
$first = 0; $page = 200;
while (true) {
$url = rtrim(FOIDC_KC_BASE,'/').'/admin/realms/'.rawurlencode(FOIDC_KC_REALM)
. '/groups?first='.$first.'&max='.$page.'&briefRepresentation=false';
$resp = foidc_http_call('GET', $url, ['headers'=>['Authorization'=>'Bearer '.$tok, 'Accept'=>'application/json']]);
if (is_wp_error($resp)) break;
$code = wp_remote_retrieve_response_code($resp);
$json = json_decode(wp_remote_retrieve_body($resp), true);
if ($code !== 200 || !is_array($json)) { foidc_log('KC groups page bad', ['code'=>$code, 'first'=>$first]); break; }
if (!count($json)) break;
$all = array_merge($all, $json);
if (count($json) < $page) break;
$first += $page;
}
return $all;
}
/* recursive finder by full path */
function foidc_kc_find_group_id_by_path($fullPath) {
$target = strtolower($fullPath);
$roots = foidc_kc_fetch_all_root_groups_full();
if (!$roots) { foidc_log('KC find group recursive: no roots'); return null; }
$walk = function($groups) use (&$walk, $target) {
foreach ($groups as $g) {
$path = isset($g['path']) ? strtolower($g['path']) : '';
if ($path === $target) return $g['id'] ?? null;
if (!empty($g['subGroups']) && is_array($g['subGroups'])) {
$found = $walk($g['subGroups']);
if ($found) return $found;
}
}
return null;
};
$id = $walk($roots);
if ($id) { foidc_log('KC find group recursive: found', ['path'=>$fullPath, 'id'=>$id]); return $id; }
/* fallback: leaf search then exact compare */
$tok = foidc_kc_admin_token();
if (!$tok) return null;
$leaf = basename($fullPath);
$surl = rtrim(FOIDC_KC_BASE,'/').'/admin/realms/'.rawurlencode(FOIDC_KC_REALM).'/groups?search='.rawurlencode($leaf);
$sresp = foidc_http_call('GET', $surl, ['headers'=>['Authorization'=>'Bearer '.$tok, 'Accept'=>'application/json']]);
if (!is_wp_error($sresp)) {
$scode = wp_remote_retrieve_response_code($sresp);
$sjson = json_decode(wp_remote_retrieve_body($sresp), true);
if ($scode === 200 && is_array($sjson)) {
foreach ($sjson as $g) {
if (!empty($g['path']) && strtolower($g['path']) === $target) {
foidc_log('KC find group fallback: found', ['path'=>$fullPath, 'id'=>$g['id']]);
return $g['id'];
}
}
}
}
foidc_log('KC find group recursive: not found', ['path'=>$fullPath]);
return null;
}
/* list direct children of a parent */
function foidc_kc_list_children($parentId, $first = 0, $max = 200) {
$tok = foidc_kc_admin_token(); if (!$tok) return [];
$base = rtrim(FOIDC_KC_BASE,'/').'/admin/realms/'.rawurlencode(FOIDC_KC_REALM);
$out = [];
while (true) {
$url = $base.'/groups/'.rawurlencode($parentId).'/children?first='.$first.'&max='.$max;
$resp = foidc_http_call('GET', $url, ['headers'=>['Authorization'=>'Bearer '.$tok,'Accept'=>'application/json']]);
if (is_wp_error($resp)) break;
$code = wp_remote_retrieve_response_code($resp);
$json = json_decode(wp_remote_retrieve_body($resp), true);
if ($code !== 200 || !is_array($json) || !count($json)) break;
$out = array_merge($out, $json);
if (count($json) < $max) break;
$first += $max;
}
return $out;
}
/* resolve exact path WITHOUT creating anything; if recursive misses, check parent's children by name (trim/lower) */
function foidc_kc_resolve_group_path_no_create($fullPath) {
$fullPath = trim($fullPath);
if ($fullPath === '' || $fullPath[0] !== '/') return null;
// Try direct
$id = foidc_kc_find_group_id_by_path($fullPath);
if ($id) return $id;
// Fallback: resolve parent then match child by name or full path (normalized)
$parentPath = rtrim(dirname($fullPath), '/');
$leaf = basename($fullPath);
if ($parentPath === '' || $parentPath === '.') return null;
$parentId = foidc_kc_find_group_id_by_path($parentPath);
if (!$parentId) {
foidc_log('resolve no-create: parent path not found', ['parent'=>$parentPath]);
return null;
}
$wantPath = strtolower($fullPath);
$wantName = strtolower(trim($leaf));
foreach (foidc_kc_list_children($parentId) as $c) {
$nm = isset($c['name']) ? strtolower(trim($c['name'])) : '';
$p = isset($c['path']) ? strtolower(trim($c['path'])) : '';
if ($nm === $wantName || $p === $wantPath) {
foidc_log('resolve no-create: child matched under parent', ['child'=>$c['name'],'id'=>$c['id'] ?? '']);
return $c['id'] ?? null;
}
}
foidc_log('resolve no-create: child not found under parent', ['path'=>$fullPath]);
return null;
}
/* ===== add user to group ===== */
function foidc_kc_add_user_to_group($kcUserId, $kcGroupId) {
$tok = foidc_kc_admin_token();
if (!$tok) return false;
$url = foidc_kc_admin_users_base() . '/' . rawurlencode($kcUserId) . '/groups/' . rawurlencode($kcGroupId);
$resp = foidc_http_call('PUT', $url, ['headers'=>['Authorization'=>'Bearer '.$tok]]);
if (is_wp_error($resp)) return false;
$code = wp_remote_retrieve_response_code($resp);
foidc_log('KC add to group done', ['code'=>$code, 'userId'=>$kcUserId, 'groupId'=>$kcGroupId]);
return ($code >= 200 && $code < 300);
}
/* ===== ensure baseline membership under EXISTING child (FIND ONLY) ===== */
function foidc_ensure_contributor_membership($identifier) {
$kcUser = foidc_kc_find_user($identifier);
if (!$kcUser || empty($kcUser['id'])) { foidc_log('ensure contrib: KC user not found', ['id'=>$identifier]); return false; }
$uid = $kcUser['id'];
$paths = foidc_kc_user_group_paths($uid); // lowercase paths
$parentPrefix = strtolower(rtrim(KC_PARENT_GROUP,'/')).'/';
foreach ($paths as $p) {
if (strpos($p, $parentPrefix) === 0) {
foidc_log('ensure contrib: already under parent', ['paths'=>$paths]);
return true;
}
}
$targetPath = rtrim(KC_PARENT_GROUP, '/').'/'.KC_CHILD_CONTRIBUTOR;
$gid = foidc_kc_resolve_group_path_no_create($targetPath); // FIND ONLY
if (!$gid) {
foidc_log('ensure contrib: target child NOT found (no-create mode)', ['path'=>$targetPath]);
return false;
}
$ok = foidc_kc_add_user_to_group($uid, $gid);
foidc_log('ensure contrib: added to EXISTING child?', ['ok'=>$ok ? 'yes' : 'no', 'path'=>$targetPath, 'gid'=>$gid]);
return $ok;
}
/* ===== ID token hint for front-channel logout ===== */
function foidc_guess_id_token() {
$candidates = ['mo_oauth_id_token', 'mo_id_token', 'id_token'];
foreach ($candidates as $ck) {
if (!empty($_COOKIE[$ck])) return $_COOKIE[$ck];
}
if (is_user_logged_in()) {
$u = wp_get_current_user();
$raw = get_user_meta($u->ID, 'mo_oauth_token_response', true);
if ($raw && is_string($raw)) {
$j = json_decode($raw, true);
if (!empty($j['id_token'])) return $j['id_token'];
}
}
return null;
}
function foidc_build_kc_logout_url($redirectTo = null) {
$redirect = $redirectTo ?: FOIDC_POST_LOGOUT_REDIRECT;
$params = ['post_logout_redirect_uri' => $redirect, 'client_id' => FOIDC_KC_CLIENT];
if ($hint = foidc_guess_id_token()) $params['id_token_hint'] = $hint;
return foidc_kc_end_session_endpoint() . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
}
function foidc_clear_miniorange_cookies() {
foreach (['mo_oauth_id_token','mo_oauth_access_token','mo_oauth_refresh_token','mo_id_token','mo_access_token','mo_refresh_token','foidc_id_token'] as $n)
if (isset($_COOKIE[$n])) setcookie($n, '', time()-3600, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
}
/* ===== Debug: dump children of the configured parent (visit once) =====
/wp-login.php?foidc_dump_parent=1 */
add_action('init', function () {
if (!isset($_GET['foidc_dump_parent'])) return;
$parent = KC_PARENT_GROUP;
$pid = foidc_kc_find_group_id_by_path($parent);
if (!$pid) { foidc_log('dump parent: parent not found', ['parent'=>$parent]); header('Content-Type:text/plain'); echo "Parent not found\n"; exit; }
$kids = foidc_kc_list_children($pid);
$names = array_map(function($g){ return ['id'=>$g['id']??'', 'name'=>$g['name']??'', 'path'=>$g['path']??'']; }, $kids);
foidc_log('dump parent: children', ['parent'=>$parent, 'children'=>$names]);
header('Content-Type:text/plain'); echo "Dumped children to wp-content/oidc-debug.log\n"; exit;
});
/* ---------------- Admin auto-redirect to SSO ---------------- */
add_action('init', function () {
if (!FOIDC_REDIRECT_ADMIN_ALWAYS) return;
if (!is_admin() || wp_doing_ajax() || is_user_logged_in()) return;
if (foidc_has_local_bypass()) {
$login = foidc_login_base_url();
foidc_log('Bypass active: /wp-admin → login', ['to' => $login]);
if (!headers_sent()) { wp_safe_redirect($login, 302); exit; }
return;
}
$uri = $_SERVER['REQUEST_URI'] ?? '';
if (foidc_guard_redirect_loop()) { foidc_log('Loop guard hit (admin)'); return; }
// If already on hidden login + oauthredirect, do nothing
$loginPath = defined('FOIDC_LOGIN_PATH') ? trim(FOIDC_LOGIN_PATH, '/') : '';
if ($loginPath && strpos($uri, $loginPath) !== false && strpos($uri, 'option=oauthredirect') !== false) return;
$oidc = foidc_build_oauthredirect(admin_url());
foidc_log('Admin without session → start SSO', ['to' => $oidc]);
if (!headers_sent()) { wp_safe_redirect($oidc, 302); exit; }
});
/* Keep login page sane */
// === LOGGED-IN health check: probe-only, no token dependence ===
add_action('init', function () {
if (!is_user_logged_in()) return;
$uri = $_SERVER['REQUEST_URI'] ?? '';
if (strpos($uri, 'wp-login.php') !== false) return;
if (isset($_GET['foidc_logout']) || isset($_GET['foidc_front_logout']) ||
(isset($_GET['option']) && $_GET['option'] === 'oauthredirect')) return;
if (!empty($_COOKIE['foidc_just_logged_in'])) return;
$last = isset($_COOKIE['foidc_last_silent_check']) ? intval($_COOKIE['foidc_last_silent_check']) : 0;
if (time() - $last < 15) return;
setcookie('foidc_last_silent_check', (string) time(), time()+300, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
if (function_exists('foidc_log')) foidc_log('Silent-check: probe-only (no token gating)');
foidc_start_silent_probe(); // sets foidc_probe_inflight and redirects with prompt=none
}, 2);
add_action('login_init', function () {
if (is_user_logged_in()) return;
$action = isset($_REQUEST['action']) ? sanitize_key($_REQUEST['action']) : '';
$has = isset($_GET['code']) || isset($_GET['state']) || isset($_GET['id_token']);
$is_or = (isset($_GET['option']) && $_GET['option'] === 'oauthredirect');
$allowed = ['logout','lostpassword','postpass','register','rp','retrievepassword'];
foidc_log('login_init', [
'action' => $action,
'has_oidc' => $has ? 'yes' : 'no',
'oauthredirect' => $is_or ? 'yes' : 'no',
'uri' => $_SERVER['REQUEST_URI'] ?? ''
]);
if ($has || $is_or || in_array($action, $allowed, true)) return;
});
/* After login → always dashboard */
add_filter('login_redirect', function ($to, $req, $user) {
if (is_wp_error($user)) return $to;
$dst = admin_url();
foidc_log('login_redirect -> dashboard', ['final' => $dst]);
return $dst;
}, 10, 3);
/* Ensure miniOrange redirect target is wp-admin */
add_action('init', function () {
if (is_user_logged_in()) return;
$cur = get_option('mo_oauth_redirect_url');
$want = admin_url();
if (!$cur || $cur === home_url()) {
update_option('mo_oauth_redirect_url', $want);
foidc_log('Set mo_oauth_redirect_url', ['value' => $want]);
}
});
/* Authenticate hook: if user exists in KC, ensure child membership and kick SSO; else block local login */
add_filter('authenticate', function ($user, $username, $password) {
if (defined('DOING_AJAX') && DOING_AJAX) return $user;
if (empty($username)) return $user;
if (foidc_has_local_bypass()) { foidc_log('Local bypass cookie present → allow WP auth'); return $user; }
if (isset($_GET['option']) && $_GET['option'] === 'oauthredirect') return $user;
$exists = foidc_kc_user_exists($username);
if ($exists) {
foidc_ensure_contributor_membership($username);
if (foidc_guard_redirect_loop()) { foidc_log('Loop guard hit (authenticate)'); return $user; }
$extra = ['login_hint' => $username, 'prompt' => 'login'];
$to = foidc_build_oauthredirect(admin_url(), $extra);
foidc_log('User found in KC → redirect to SSO', ['username' => $username, 'to' => $to]);
if (!headers_sent()) { wp_safe_redirect($to, 302); exit; }
return null;
}
foidc_log('User NOT in KC → block local login', ['username' => $username]);
return new WP_Error('kc_only', __('Please sign in via Single Sign-On. Your account must exist in Keycloak.'));
}, 1, 3);
/* After SSO return → ensure contributor + seed access token cookie for silent-check */
add_action('wp_login', function($user_login, $user){
$identifier = $user->user_email ?: $user->user_login;
foidc_log('wp_login → ensure contributor in KC', ['identifier'=>$identifier]);
foidc_ensure_contributor_membership($identifier);
// Prime from usermeta/cookies/options
$primed = foidc_prime_tokens_for($user->ID);
if (!$primed) foidc_log('wp_login → token priming failed (will rely on silent-probe)');
// Grace window to skip immediate re-check
setcookie('foidc_just_logged_in', '1', time()+20, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
}, 10, 2);
/* ---------------- WordPress Logout (RP-initiated) ---------------- */
add_filter('logout_url', function($logout_url, $redirect) {
$nonce = wp_create_nonce('log-out');
$args = ['foidc_logout'=>1,'_wpnonce'=>$nonce];
if (!empty($redirect)) $args['redirect_to'] = $redirect;
return add_query_arg($args, home_url('/'));
},10,2);
add_action('init', function () {
if (!isset($_GET['foidc_logout'])) return;
if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'log-out')) wp_die(__('Security check failed.'));
$redirect_after = !empty($_GET['redirect_to']) ? esc_url_raw($_GET['redirect_to']) : FOIDC_POST_LOGOUT_REDIRECT;
foidc_log('WP logout triggered');
wp_logout();
foidc_clear_miniorange_cookies();
if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
$kcLogout = foidc_build_kc_logout_url($redirect_after);
foidc_set_local_bypass_cookie();
nocache_headers();
?>
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Logging out…</title></head>
<body>
<script>
(function(){
try { localStorage.setItem('foidc_global_logout', String(Date.now())); } catch(e){}
window.location.replace(<?php echo json_encode($kcLogout); ?>);
})();
</script>
<noscript><p>Logging out… <a href="<?php echo esc_attr($kcLogout); ?>">Click here</a></p></noscript>
</body></html>
<?php
exit;
});
/* ---------- Helpers for custom login path (WPS Hide Login aware) ---------- */
function foidc_login_base_url() {
// if you defined FOIDC_LOGIN_PATH ('cotocus1208'), build from home_url
if (defined('FOIDC_LOGIN_PATH') && FOIDC_LOGIN_PATH) {
// https://example.com/cotocus1208/
return home_url('/' . trim(FOIDC_LOGIN_PATH, '/') . '/');
}
// fallback to normal login
$base = strtok(wp_login_url(), '?');
return trailingslashit($base);
}
function foidc_login_url_build(array $args = []) {
$url = foidc_login_base_url();
return $args ? add_query_arg($args, $url) : $url;
}
/* ---------- Silent probe helpers (PROMPT=NONE) ---------- */
function foidc_should_probe_now() {
$last = isset($_COOKIE['foidc_last_probe']) ? intval($_COOKIE['foidc_last_probe']) : 0;
if (time() - $last < 60) return false; // throttle 1/min
setcookie('foidc_last_probe', (string) time(), time()+300, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
return true;
}
function foidc_start_silent_probe($redirect_to = null) {
$extra = [
'prompt' => 'none',
'foidc_probe' => '1',
];
$to = foidc_build_oauthredirect($redirect_to ?: admin_url(), $extra);
foidc_log('Silent-probe → oauthredirect (prompt=none)', ['to'=>$to]);
setcookie('foidc_probe_inflight', '1', time()+120, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
if (!headers_sent()) { wp_safe_redirect($to, 302); exit; }
<?php
echo '<meta http-equiv="refresh" content="0;url='.esc_attr($to).'>';
?> exit;
}
/* ---------------- Broadcast listener on all pages ---------------- */
function foidc_inject_broadcast_script() {
?>
<script>
(function(){
window.addEventListener('foidc-broadcast-logout', function(){
try { localStorage.setItem('foidc_global_logout', String(Date.now())); } catch(e){}
});
window.addEventListener('storage', function(ev){
if (ev && ev.key === 'foidc_global_logout' && ev.newValue) {
try { window.location.href = '<?php echo esc_js( wp_login_url() ); ?>?reauth=1'; } catch(e){}
}
});
})();
</script>
<?php
}
add_action('wp_footer', 'foidc_inject_broadcast_script');
add_action('admin_footer', 'foidc_inject_broadcast_script');
add_action('login_footer', 'foidc_inject_broadcast_script');