From d21498f1d501d0b0f50265023b31535f571c83dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean=20Charles=20Del=C3=A9pine?= Date: Tue, 26 May 2026 22:01:05 +0200 Subject: [PATCH] feat: XOAUTH2 hooks for OIDC authentication Adds OIDC/XOAUTH2 hook examples to hooks.php.dist: - imap_preauthenticate: injects a Horde_Imap_Client_Password_Xoauth2 object when the user has OAuth2 tokens stored, falling back to password auth otherwise. - dynamic_prefs: proactively refreshes OAuth2 tokens before they expire during a session, and updates the IMAP password stored in the Horde session so active connections use the new token. Also relaxes the credential check in IMP_Auth to accept either a password or a xoauth2_token, allowing OIDC sessions to authenticate without a stored password. All hook code is commented out in the .dist file and must be explicitly activated by the administrator. Depends on: - horde/Core: OIDC integration (PR #160) --- config/hooks.php.dist | 90 +++++++++++++++++++++++++++++++++++++++++++ lib/Auth.php | 49 +++++++++++++++++++++-- 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/config/hooks.php.dist b/config/hooks.php.dist index b5f9ac886..0580c0e99 100644 --- a/config/hooks.php.dist +++ b/config/hooks.php.dist @@ -100,6 +100,60 @@ class IMP_Hooks // return $credentials; // } + // ========================================================================= + // OIDC / XOAUTH2 hooks + // ========================================================================= + // These hooks enable XOAUTH2 authentication for IMAP when the user has + // logged in via an OIDC provider (e.g. Apereo CAS, Keycloak). + // Requires horde/Core >= 6.x with OAuthTokenService configured. + // + // To activate: uncomment and place in var/config/imp/hooks.php + // ========================================================================= + + /** + * imap_preauthenticate + * + * Called by IMP before opening an IMAP connection. + * Injects a Horde_Imap_Client_Password_Xoauth2 object if the user has + * OAuth2 tokens stored. Falls back to password auth if not. + * + * @param array $credentials Current credentials array + * @return array Modified credentials + */ +// public function imap_preauthenticate(array $credentials): array +// { +// global $injector; +// +// $username = $injector->getInstance('Horde_Registry')->getAuth(); +// if (!$username) { +// return $credentials; +// } +// +// $tokenService = $injector->getInstance(\Horde\Core\Service\OAuthTokenService::class); +// $providerConfig = $injector->getInstance(\Horde\Core\Service\OAuthProviderConfigRepository::class); +// +// $row = \Horde\Core\Service\OidcHookHelper::findProviderForUser( +// $username, $tokenService, $providerConfig +// ); +// if ($row === null) { +// return $credentials; +// } +// +// $accessToken = \Horde\Core\Service\OidcHookHelper::getValidAccessToken( +// $username, $row, $tokenService, $injector +// ); +// if ($accessToken === null) { +// return $credentials; +// } +// +// $xoauth2User = \Horde\Core\Service\OidcHookHelper::xoauth2Username($username, $row); +// $credentials['password'] = new Horde_Imap_Client_Password_Xoauth2( +// $xoauth2User, +// $accessToken +// ); +// +// return $credentials; +// } /** * PREFERENCE INIT: Set preference values on login. @@ -522,6 +576,42 @@ class IMP_Hooks */ public function dynamic_prefs() { + // --------------------------------------------------------------- + // OIDC/XOAUTH2: proactive token refresh + // Uncomment to refresh OAuth2 tokens before they expire in IMP. + // Requires horde/Core >= 6.x with OAuthTokenService configured. + // --------------------------------------------------------------- + // global $injector; + // $tokenService = $injector->getInstance(\Horde\Core\Service\OAuthTokenService::class); + // $providerConfig = $injector->getInstance(\Horde\Core\Service\OAuthProviderConfigRepository::class); + // $username = $injector->getInstance('Horde_Registry')->getAuth(); + // if ($username) { + // $row = \Horde\Core\Service\OidcHookHelper::findProviderForUser( + // $username, $tokenService, $providerConfig + // ); + // if ($row !== null) { + // try { + // $tokenSet = $tokenService->getTokenSet($username, $row['provider_id']); + // if ($tokenSet->isExpired(300)) { + // $accessToken = \Horde\Core\Service\OidcHookHelper::getValidAccessToken( + // $username, $row, $tokenService, $injector + // ); + // if ($accessToken !== null) { + // $xoauth2User = \Horde\Core\Service\OidcHookHelper::xoauth2Username($username, $row); + // $xoauth2Obj = new Horde_Imap_Client_Password_Xoauth2($xoauth2User, $accessToken); + // $session = $injector->getInstance('Horde_Session'); + // foreach (array_keys($_SESSION['imp'] ?? []) as $key) { + // if (str_starts_with($key, 'IMP_Imap_Password/')) { + // $session->set('imp', $key, $xoauth2Obj, $session::ENCRYPT); + // } + // } + // } + // } + // } catch (\Throwable $e) {} + // } + // } + // --------------------------------------------------------------- + return array( /* Preview pane. Valid values: * - 'horiz': Horizontal mode diff --git a/lib/Auth.php b/lib/Auth.php index a45462f55..1d380e858 100644 --- a/lib/Auth.php +++ b/lib/Auth.php @@ -33,6 +33,8 @@ class IMP_Auth * - password: (string) The user password. * - server: (string) The server key to use (from backends.php). * - userId: (string) The username. + * - xoauth2_token: (Horde_Imap_Client_Password_Xoauth2) XOAUTH2 token + * (alternative to password, used with OIDC auth). * * @throws Horde_Auth_Exception */ @@ -59,7 +61,8 @@ public static function authenticate($credentials = []) // Check for valid IMAP Client object. if (!$imp_imap->init) { if (!isset($credentials['userId']) - || !isset($credentials['password'])) { + || (!isset($credentials['password']) + && !isset($credentials['xoauth2_token']))) { throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN); } @@ -74,7 +77,11 @@ public static function authenticate($credentials = []) } try { - $imp_imap->createBaseImapObject($credentials['userId'], $credentials['password'], $credentials['server']); + $imp_imap->createBaseImapObject( + $credentials['userId'], + $credentials['xoauth2_token'] ?? $credentials['password'], + $credentials['server'] + ); } catch (IMP_Imap_Exception $e) { self::_log(false, $imp_imap); throw $e->authException(); @@ -112,8 +119,8 @@ public static function transparent($auth_ob) $credentials['userId'] = $auth_ob->getCredential('userId'); } - if (!isset($credentials['password']) - || !strlen($credentials['password'])) { + if (empty($credentials['xoauth2_token']) + && empty($credentials['password'])) { return false; } @@ -204,6 +211,40 @@ protected static function _canAutoLogin($server_key = null, $force = false) { global $injector, $registry; + // OIDC/XOAUTH2: only if auth driver is oidc. + // Must come before loadServerConfig() which may fail when IMP is not + // fully initialised (e.g. portal rendering before IMP appInit). + if (!empty($GLOBALS['conf']['auth']['driver']) + && strcasecmp($GLOBALS['conf']['auth']['driver'], 'oidc') === 0) { + $username = $registry->getAuth(); + if ($username) { + $tokenService = $injector->getInstance(\Horde\Core\Service\OAuthTokenService::class); + $providerConfig = $injector->getInstance(\Horde\Core\Service\OAuthProviderConfigRepository::class); + $row = \Horde\Core\Service\OidcHookHelper::findProviderForUser( + $username, $tokenService, $providerConfig + ); + if ($row !== null) { + $accessToken = \Horde\Core\Service\OidcHookHelper::getValidAccessToken( + $username, $row, $tokenService, $injector + ); + if ($accessToken !== null) { + $xoauth2User = \Horde\Core\Service\OidcHookHelper::xoauth2Username( + $username, $row + ); + return [ + 'userId' => $xoauth2User, + 'xoauth2_token' => new Horde_Imap_Client_Password_Xoauth2( + $xoauth2User, $accessToken + ), + 'server' => $server_key ?? self::getAutoLoginServer(), + ]; + } + } + } + // OIDC driver but no tokens available — do not fall through to hordeauth + return false; + } + if (($servers = $injector->getInstance('IMP_Factory_Imap')->create()->loadServerConfig()) === false) { return false; }