Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion config/hooks.php.dist
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ class IMP_Hooks
// return $credentials;
// }


/**
* PREFERENCE INIT: Set preference values on login.
*
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class IMP_Application extends Horde_Registry_Application
'loginparams',
'remove',
'transparent',
'validate',
];

/**
Expand Down Expand Up @@ -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.
Expand Down
117 changes: 108 additions & 9 deletions lib/Auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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);
}

Expand All @@ -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();
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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'),
Expand All @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion lib/Imap/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
];

/**
Expand Down
Loading