diff --git a/config/hooks.php.dist b/config/hooks.php.dist index b5f9ac886..0bf793721 100644 --- a/config/hooks.php.dist +++ b/config/hooks.php.dist @@ -100,7 +100,6 @@ class IMP_Hooks // return $credentials; // } - /** * PREFERENCE INIT: Set preference values on login. * @@ -522,6 +521,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/Application.php b/lib/Application.php index 803387c4b..0516b4121 100644 --- a/lib/Application.php +++ b/lib/Application.php @@ -56,6 +56,7 @@ class IMP_Application extends Horde_Registry_Application 'loginparams', 'remove', 'transparent', + 'validate', ]; /** @@ -266,6 +267,11 @@ public function authTransparent($auth_ob) return IMP_Auth::transparent($auth_ob); } + public function authValidate($params = []) + { + return IMP_Auth::validateOauth(); + } + /** * @param array $credentials An array of login credentials. For IMP, * this must contain a password entry. diff --git a/lib/Auth.php b/lib/Auth.php index fab970607..23f173940 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(); @@ -204,8 +211,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; } @@ -311,21 +318,61 @@ public static function getAutoLoginServer() protected static function _canAutoLogin($server_key = null, $force = false) { global $injector, $registry; - if (($servers = $injector->getInstance('IMP_Factory_Imap')->create()->loadServerConfig()) === false) { return false; } - if (is_null($server_key) || !$force) { $auto_server = self::getAutoLoginServer(); if (is_null($server_key)) { $server_key = $auto_server; } } + if (empty($auto_server) && !$force) { + return false; + } + if (!$registry->getAuth()) { + return false; + } - if ((!empty($auto_server) || $force) - && $registry->getAuth() - && !empty($servers[$server_key]->hordeauth)) { + // If this backend declares an associated OAuth provider via + // 'oauth' => 'provider-id' in backends.php, try XOAUTH2. + if (!empty($servers[$server_key]->oauth)) { + $username = $registry->getAuth(); + $tokenService = $injector->getInstance(\Horde\Core\Service\OAuthTokenService::class); + $providerConfig = $injector->getInstance(\Horde\Core\Service\OAuthProviderConfigRepository::class); + + try { + $row = $providerConfig->get($servers[$server_key]->oauth); + } catch (\Throwable $e) { + Horde::log( + sprintf('IMP: backend "%s" declares oauth provider "%s" which does not exist: %s', + $server_key, $servers[$server_key]->oauth, $e->getMessage()), + 'ERR' + ); + return false; + } + if ($tokenService->hasTokens($username, $row['provider_id'])) { + $accessToken = \Horde\Core\Service\OidcHookHelper::getValidAccessToken( + $username, $row, $tokenService, $injector + ); + + if ($accessToken !== null) { + $xoauth2User = \Horde\Core\Service\OidcHookHelper::xoauth2Username( + $username, $row + ); + return [ + 'userId' => $xoauth2User, + 'password' => new Horde_Imap_Client_Password_Xoauth2( + $xoauth2User, $accessToken + ), + 'server' => $server_key, + ]; + } + } + return false; + } + + if (!empty($servers[$server_key]->hordeauth)) { return [ 'userId' => $registry->getAuth((strcasecmp($servers[$server_key]->hordeauth, 'full') === 0) ? null : 'bare'), 'password' => $registry->getAuthCredential('password'), @@ -336,6 +383,58 @@ protected static function _canAutoLogin($server_key = null, $force = false) return false; } + /** + * Validate that any required OAuth tokens are still present for the + * currently active backend. + * + * Called via IMP_Application::authValidate() on every request + * (checkExistingAuth()), so that a backchannel logout / token + * revocation is detected promptly rather than only at the next full + * re-authentication. + * + * @return boolean True if valid (or if this backend doesn't require + * OAuth), false if OAuth is required but no tokens + * remain. + */ + public static function validateOauth() + { + global $injector, $registry; + + $imp_imap = $injector->getInstance('IMP_Factory_Imap')->create(); + if (!$imp_imap->init) { + // No active connection to validate against. + return true; + } + + $server_key = $imp_imap->server_key; + if (($servers = $imp_imap->loadServerConfig()) === false + || empty($servers[$server_key]->oauth)) { + // Not an OAuth-backed backend — nothing to validate here. + return true; + } + + $username = $registry->getAuth(); + if (!$username) { + return true; + } + + $tokenService = $injector->getInstance(\Horde\Core\Service\OAuthTokenService::class); + $providerConfig = $injector->getInstance(\Horde\Core\Service\OAuthProviderConfigRepository::class); + + try { + $row = $providerConfig->get($servers[$server_key]->oauth); + } catch (\Throwable $e) { + Horde::log( + sprintf('IMP: validateOauth() could not resolve oauth provider "%s" for backend "%s": %s', + $servers[$server_key]->oauth, $server_key, $e->getMessage()), + 'ERR' + ); + return true; // fail-open: infra issue shouldn't log the user out + } + + return $tokenService->hasTokens($username, $row['provider_id']); + } + /** * Perform post-login tasks. Session creation requires the full IMP * environment, which is not available until this callback. diff --git a/lib/Imap/Config.php b/lib/Imap/Config.php index b0f655d16..c2109ffcb 100644 --- a/lib/Imap/Config.php +++ b/lib/Imap/Config.php @@ -34,6 +34,9 @@ * @property string $debug The debug handler. * @property boolean $debug_raw Do raw debugging? * @property mixed $hordeauth Type of horde authentication to use. + * @property string $oauth Provider ID of the associated OAuth/OIDC + * provider (see horde_oauth_providers), if this + * backend authenticates via XOAUTH2. * @property string $hostspec Hostspec of the backend. * @property array $id List of ID information to send via the ID command. * @property-read integer $import_limit The maximum number of messages @@ -131,7 +134,7 @@ class IMP_Imap_Config implements Serializable */ private $_soptions = [ 'cache_lifetime', 'comparator', 'debug', 'hostspec', 'import_limit', - 'maildomain', 'name', 'port', 'protocol', 'thread', 'timeout', + 'maildomain', 'name', 'oauth', 'port', 'protocol', 'thread', 'timeout', ]; /**