From e42175ae6c699efee134915b9e79982deba32d93 Mon Sep 17 00:00:00 2001 From: Hgamo <73846452+Hgamo@users.noreply.github.com> Date: Tue, 22 Jul 2025 18:43:04 +0200 Subject: [PATCH] Implement OIDC logout support and configuration options Fixes #5774 --- data/conf/sogo/custom-sogo.js | 20 ++++--- data/web/inc/functions.inc.php | 53 +++++++++++++++++++ data/web/inc/sessions.inc.php | 36 ++++++++++++- data/web/lang/lang.de-de.json | 2 + data/web/lang/lang.en-gb.json | 2 + .../admin/tab-config-identity-provider.twig | 26 +++++++++ 6 files changed, 130 insertions(+), 9 deletions(-) diff --git a/data/conf/sogo/custom-sogo.js b/data/conf/sogo/custom-sogo.js index d3b90b085..c10c0cfb5 100644 --- a/data/conf/sogo/custom-sogo.js +++ b/data/conf/sogo/custom-sogo.js @@ -7,13 +7,19 @@ document.addEventListener('DOMContentLoaded', function () { }); // logout function function mc_logout() { - fetch("/", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded" - }, - body: "logout=1" - }).then(() => window.location.href = '/'); + // Create and submit a logout form to trigger the logout process + var form = document.createElement('form'); + form.method = 'POST'; + form.action = '/'; + + var logoutInput = document.createElement('input'); + logoutInput.type = 'hidden'; + logoutInput.name = 'logout'; + logoutInput.value = '1'; + + form.appendChild(logoutInput); + document.body.appendChild(form); + form.submit(); } // Custom SOGo JS diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index edf428d5a..7c5166111 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -2307,6 +2307,10 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { if (!array_key_exists('login_provisioning', $settings)) { $settings['login_provisioning'] = 1; } + // set end_session_url default if not exists + if (!array_key_exists('end_session_url', $settings)) { + $settings['end_session_url'] = ''; + } // return default client_scopes for generic-oidc if none is set if ($settings["authsource"] == "generic-oidc" && empty($settings["client_scopes"])){ $settings["client_scopes"] = "openid profile email mailcow_template"; @@ -2381,6 +2385,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { $_data['import_users'] = isset($_data['import_users']) ? intval($_data['import_users']) : 0; $_data['sync_interval'] = (!empty($_data['sync_interval'])) ? intval($_data['sync_interval']) : 15; $_data['sync_interval'] = $_data['sync_interval'] < 1 ? 1 : $_data['sync_interval']; + $_data['end_session_url'] = (isset($_data['end_session_url']) && trim($_data['end_session_url']) !== '') ? trim($_data['end_session_url']) : null; $required_settings = array('authsource', 'server_url', 'realm', 'client_id', 'client_secret', 'redirect_url', 'version', 'mailpassword_flow', 'periodic_sync', 'import_users', 'sync_interval', 'ignore_ssl_error', 'login_provisioning'); break; case "generic-oidc": @@ -2388,6 +2393,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { $_data['token_url'] = (!empty($_data['token_url'])) ? $_data['token_url'] : null; $_data['userinfo_url'] = (!empty($_data['userinfo_url'])) ? $_data['userinfo_url'] : null; $_data['client_scopes'] = (!empty($_data['client_scopes'])) ? $_data['client_scopes'] : "openid profile email mailcow_template"; + $_data['end_session_url'] = (isset($_data['end_session_url']) && trim($_data['end_session_url']) !== '') ? trim($_data['end_session_url']) : null; $required_settings = array('authsource', 'authorize_url', 'token_url', 'client_id', 'client_secret', 'redirect_url', 'userinfo_url', 'client_scopes', 'ignore_ssl_error', 'login_provisioning'); break; case "ldap": @@ -2446,6 +2452,12 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { $stmt->execute(); } + // add end_session_url + $_data['end_session_url'] = (isset($_data['end_session_url']) && trim($_data['end_session_url']) !== '') ? trim($_data['end_session_url']) : ""; + $stmt = $pdo->prepare("INSERT INTO identity_provider (`key`, `value`) VALUES ('end_session_url', :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);"); + $stmt->bindParam(':value', $_data['end_session_url']); + $stmt->execute(); + // add mappers if (isset($_data['mappers']) && isset($_data['templates'])){ $_data['mappers'] = (!is_array($_data['mappers'])) ? array($_data['mappers']) : $_data['mappers']; @@ -2764,6 +2776,11 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { set_user_loggedin_session($info['email']); $_SESSION['iam_token'] = $plain_token; $_SESSION['iam_refresh_token'] = $plain_refreshtoken; + $_SESSION['iam_auth_source'] = $iam_settings['authsource']; + // Store ID token if available for logout + if (isset($token->getValues()['id_token'])) { + $_SESSION['iam_id_token'] = $token->getValues()['id_token']; + } $_SESSION['return'][] = array( 'type' => 'success', 'log' => array(__FUNCTION__, $_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role']), @@ -2840,6 +2857,11 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { set_user_loggedin_session($info['email']); $_SESSION['iam_token'] = $plain_token; $_SESSION['iam_refresh_token'] = $plain_refreshtoken; + $_SESSION['iam_auth_source'] = $iam_settings['authsource']; + // Store ID token if available for logout + if (isset($token->getValues()['id_token'])) { + $_SESSION['iam_id_token'] = $token->getValues()['id_token']; + } $_SESSION['return'][] = array( 'type' => 'success', 'log' => array(__FUNCTION__, $_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role']), @@ -2953,6 +2975,37 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { )); return $res['access_token']; break; + case "get-logout-url": + // Generate logout URL for OIDC providers + if ($iam_settings['authsource'] != 'keycloak' && $iam_settings['authsource'] != 'generic-oidc') { + return false; + } + + // Build the logout URL according to oidc spec + // If end_session_url is empty, OIDC logout is disabled + if (empty($iam_settings['end_session_url'])) { + return false; + } + + $post_logout_redirect_uri = (!empty($_data['post_logout_redirect_uri'])) ? $_data['post_logout_redirect_uri'] : null; + + $params = []; + if ($post_logout_redirect_uri) { + $params['post_logout_redirect_uri'] = $post_logout_redirect_uri; + } + + // Add id_token_hint if available in session + if (isset($_SESSION['iam_id_token'])) { + $params['id_token_hint'] = $_SESSION['iam_id_token']; + } + + $logout_url = $iam_settings['end_session_url']; + if (!empty($params)) { + $logout_url .= '?' . http_build_query($params); + } + + return $logout_url; + break; } } function reset_password($action, $data = null) { diff --git a/data/web/inc/sessions.inc.php b/data/web/inc/sessions.inc.php index bbc08cf13..1f9cd9a26 100644 --- a/data/web/inc/sessions.inc.php +++ b/data/web/inc/sessions.inc.php @@ -110,12 +110,44 @@ if (isset($_POST["logout"])) { } else { $role = $_SESSION["mailcow_cc_role"]; + + // Check if user was authenticated via OIDC and OIDC logout is enabled + $oidc_logout_url = null; + if (isset($_SESSION['iam_auth_source']) && ($_SESSION['iam_auth_source'] == 'keycloak' || $_SESSION['iam_auth_source'] == 'generic-oidc')) { + require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.inc.php'; + + // Determine the schema + $schema = 'http'; + if ((isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) == "https") || + isset($_SERVER['HTTPS'])) { + $schema = 'https'; + } + + // Determine post-logout redirect URI based on role + $post_logout_redirect_uri = $schema . '://' . $_SERVER['HTTP_HOST']; + if ($role == "admin") { + $post_logout_redirect_uri .= '/admin'; + } elseif ($role == "domainadmin") { + $post_logout_redirect_uri .= '/domainadmin'; + } else { + $post_logout_redirect_uri .= '/'; + } + + $iam_provider = identity_provider('init'); + $iam_settings = identity_provider('get'); + $oidc_logout_url = identity_provider('get-logout-url', array('post_logout_redirect_uri' => $post_logout_redirect_uri)); + } + session_regenerate_id(true); session_unset(); session_destroy(); session_write_close(); - if ($role == "admin") { - header("Location: /admin"); + + // Redirect to OIDC logout URL if available, otherwise use standard logout + if ($oidc_logout_url) { + header("Location: " . $oidc_logout_url); + } elseif($role == "admin") { + header("Location: /admin"); } elseif ($role == "domainadmin") { header("Location: /domainadmin"); diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index df41b8946..4be5ca0eb 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -241,6 +241,8 @@ "iam_test_connection": "Verbindung Testen", "iam_token_url": "Token Endpunkt", "iam_userinfo_url": "User info Endpunkt", + "iam_end_session_url": "End-Session-Endpunkt", + "iam_end_session_url_info": "URL für RP-Initiated Logout nach OpenID Connect Spezifikation. Leer lassen, um OIDC-Logout zu deaktivieren.", "iam_username_field": "Username Feld", "iam_binddn": "Bind DN", "iam_use_ssl": "Benutze SSL", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 727fd687c..905f0bf2d 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -248,6 +248,8 @@ "iam_test_connection": "Test Connection", "iam_token_url": "Token endpoint", "iam_userinfo_url": "User info endpoint", + "iam_end_session_url": "End session endpoint", + "iam_end_session_url_info": "URL for RP-Initiated Logout according to OpenID Connect specification. Leave empty to disable OIDC logout.", "iam_username_field": "Username Field", "iam_binddn": "Bind DN", "iam_use_ssl": "Use SSL", diff --git a/data/web/templates/admin/tab-config-identity-provider.twig b/data/web/templates/admin/tab-config-identity-provider.twig index 4572d7fb5..f06ef163d 100644 --- a/data/web/templates/admin/tab-config-identity-provider.twig +++ b/data/web/templates/admin/tab-config-identity-provider.twig @@ -257,6 +257,19 @@ +
+
+ +
+
+ +

+ + {{ lang.admin.iam_end_session_url_info }} + +

+
+
@@ -460,6 +473,19 @@
+
+
+ +
+
+ +

+ + {{ lang.admin.iam_end_session_url_info }} + +

+
+