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
25 changes: 25 additions & 0 deletions config/conf.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2716,5 +2716,30 @@ cookie policy. See session configuration options.">$_SERVER['SERVER_NAME'] ?? $_
</configswitch>

</configsection>

</configtab>
<configtab name="oauth" desc="OAuth / OIDC Tokens">
<configsection name="oauth">
<configheader>OAuth / OIDC Token Storage</configheader>
<configdescription>Configure where OAuth2 access and refresh tokens are
stored server-side. These tokens are required when Horde authenticates
users via OIDC (auth driver = oidc) so that applications such as IMP
can connect to IMAP and SMTP servers using XOAUTH2 without storing the
user password in the Horde session. Must be set to SQL for any
multi-worker or production deployment.</configdescription>

<configswitch name="token_driver" desc="Token storage backend">null
<case name="null" desc="None — tokens are not persisted (testing only,
single-process only)">
</case>
<case name="sql" desc="SQL Database (required for production OIDC
deployments and multi-worker setups)">
<configsection name="token_params">
<configsql switchname="driverconfig" />
</configsection>
</case>
</configswitch>

</configsection>
</configtab>
</configuration>
51 changes: 51 additions & 0 deletions config/hooks.php.dist
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,19 @@
* credentials.
* (array) - Replace the credentials with the given value.
*
* smtp pre-authentication. This hook allows
* modification of credentials immediately before authentication to the
* SMTP server. This gives the chance to modify the username and
* have it affect ONLY the IMAP authentication process. I.e., the modified
* username is NOT visible to Horde or any application. This is useful e.g.
* when the IMAP server requires prepending the user's IP address to the
* username for audit purposes.
*
* @param array $credentials An array containing authentication credentials
* - userId: (string) The authentication username.
*
* @return array The possibly modified authentication credentials.
*
* authusername
* ------------
* This hook is used to dynamically convert between an authentication username
Expand Down Expand Up @@ -1302,5 +1315,43 @@ class Horde_Hooks
// return $map[$deviceId] ?? null;
// }

/**
* smtp_credentials
*
* Called by Horde_Core_Factory_Mail when creating the SMTP transport.
*
* @param string $username Authenticated Horde username
* @return array Credentials with 'xoauth2_token' and 'username' keys
* @throws Horde_Exception_HookNotSet If XOAUTH2 not applicable
*/
// public function smtp_credentials(string $username): array
// {
// global $injector;
//
// $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) {
// throw new Horde_Exception_HookNotSet();
// }
//
// $accessToken = \Horde\Core\Service\OidcHookHelper::getValidAccessToken(
// $username, $row, $tokenService, $injector
// );
// if ($accessToken === null) {
// throw new Horde_Exception_HookNotSet();
// }
//
// $xoauth2User = \Horde\Core\Service\OidcHookHelper::xoauth2Username($username, $row);
//
// return [
// 'xoauth2_token' => new Horde_Smtp_Password_Xoauth2($xoauth2User, $accessToken),
// 'username' => $xoauth2User,
// ];
// }


}
15 changes: 14 additions & 1 deletion config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
->withController(Auth\OAuthLoginController::class)
->withDefaults(['HordeAuthType' => 'NONE'])
->withMiddleware([AuthHordeSession::class])
->withMethods(['POST'])
->withMethods(['POST', 'GET'])
->add();

// Responsive UI Routes - Phase 1: Portal
Expand Down Expand Up @@ -635,3 +635,16 @@
])
->withMethods(['GET'])
->add();

// OIDC Back-Channel Logout (RFC 9470)
// Receives a signed logout_token JWT from the identity provider.
// POST only, no Horde session required (the IdP has no session with us).
// Always returns 200 to prevent provider retries.
// Configure in your IdP (Apereo CAS): logoutType=BACK_CHANNEL, logoutUrl=<this URL>
$mapper->buildRoute(uri: '/auth/oidc/backchannel-logout', name: 'OidcBackchannelLogout')
->withController(\Horde\Core\Auth\OidcBackchannelLogoutController::class)
->withDefaults(['HordeAuthType' => 'NONE'])
->withMiddleware([HordeCore::class, ErrorFilter::class])
->withMethods(['POST'])
->add();

33 changes: 13 additions & 20 deletions login.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ function _collectAppLoginParams($injector, $registry, $logger = null, $collectPo
}

if ($logout_reason) {
$postLogoutData = ['redirect' => null];
if ($is_auth) {
try {
$tokenService = $injector->getInstance(Token::class);
Expand All @@ -223,36 +224,28 @@ function _collectAppLoginParams($injector, $registry, $logger = null, $collectPo
require HORDE_BASE . '/index.php';
exit;
}
$is_auth = null;

$logger->notice(sprintf(
'User %s logged out of Horde (%s)%s',
$registry->getAuth(),
$_SERVER['REMOTE_ADDR'],
empty($_SERVER['HTTP_X_FORWARDED_FOR']) ? '' : ' (forwarded for [' . $_SERVER['HTTP_X_FORWARDED_FOR'] . '])'
$loginService = $injector->getInstance(\Horde\Horde\Service\LoginService::class);
$postLogoutData = $loginService->performLogout(new \Horde\Horde\ValueObject\LogoutRequest(
csrfToken: null, // already verified above
reason: $logout_reason,
remoteAddr: $_SERVER['REMOTE_ADDR'],
forwardedFor: $_SERVER['HTTP_X_FORWARDED_FOR'] ?? null,
));
$is_auth = null;
} else {
$registry->clearAuth();
$session->setup();
}

$registry->clearAuth();

/* Reset notification handler now, since it may still be using a status
* handler that is no longer valid. */
$notification->detach('status');
$notification->attach('status');

/* Redirect the user on logout if redirection is enabled and this is an
* an intended logout. */
if (($logout_reason == Horde_Auth::REASON_LOGOUT)
&& !empty($conf['auth']['redirect_on_logout'])) {
$logout_url = new Horde_Url($conf['auth']['redirect_on_logout'], true);
if (!empty($postLogoutData['redirect'])) {
$logout_url = new Horde_Url($postLogoutData['redirect'], true);
if (!isset($_COOKIE[session_name()])) {
$logout_url->add(session_name(), session_id());
}
_addAnchor($logout_url, 'url', $vars, $url_anchor)->redirect();
}

$session->setup();

/* Explicitly set language in un-authenticated session. */
$registry->setLanguage($GLOBALS['language']);

Expand Down
49 changes: 49 additions & 0 deletions migration/3_horde_oauth_providers_oidc_fields.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
// migration/3_horde_oauth_providers_oidc_fields.php

class HordeOauthProvidersOidcFields extends Horde_Db_Migration_Base
{
public function up()
{
$cols = array_column(
$this->columns('horde_oauth_providers'),
null,
'name'
);

if (!isset($cols['logout_type'])) {
$this->addColumn('horde_oauth_providers', 'logout_type',
'string', ['limit' => 20, 'default' => 'local']);
}
if (!isset($cols['end_session_endpoint'])) {
$this->addColumn('horde_oauth_providers', 'end_session_endpoint',
'string', ['limit' => 1024]);
}
if (!isset($cols['post_logout_redirect_uri'])) {
$this->addColumn('horde_oauth_providers', 'post_logout_redirect_uri',
'string', ['limit' => 1024]);
}
if (!isset($cols['backchannel_username_claim'])) {
$this->addColumn('horde_oauth_providers', 'backchannel_username_claim',
'string', ['limit' => 64, 'default' => 'sub']);
}
if (!isset($cols['xoauth2_use_email'])) {
$this->addColumn('horde_oauth_providers', 'xoauth2_use_email',
'integer', ['default' => 0]);
}
if (!isset($cols['xoauth2_domain'])) {
$this->addColumn('horde_oauth_providers', 'xoauth2_domain',
'string', ['limit' => 255]);
}
}

public function down()
{
foreach ([
'logout_type', 'end_session_endpoint', 'post_logout_redirect_uri',
'backchannel_username_claim', 'xoauth2_use_email', 'xoauth2_domain',
] as $col) {
$this->removeColumn('horde_oauth_providers', $col);
}
}
}
6 changes: 6 additions & 0 deletions src/Admin/OAuthProviderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ private function extractUpdateData(array $existing, array $body): array
'revocation_endpoint', 'introspection_endpoint',
'default_scopes',
'app_identifier', 'installation_id',
'logout_type', 'end_session_endpoint', 'post_logout_redirect_uri',
'backchannel_username_claim', 'xoauth2_use_email', 'xoauth2_domain',
];

$data = [];
Expand All @@ -288,6 +290,10 @@ private function extractUpdateData(array $existing, array $body): array
}
}

if (isset($data['xoauth2_use_email'])) {
$data['xoauth2_use_email'] = (int) ($data['xoauth2_use_email'] ?? 0);
}

if ($data['enabled'] ?? null !== null) {
$data['enabled'] = (int) ($data['enabled'] ?? 1);
}
Expand Down
6 changes: 4 additions & 2 deletions src/Auth/OAuthLoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ public function handle(ServerRequestInterface $request): ResponseInterface
return $this->redirect($loginUrl . '?error=failed');
}

$body = $request->getParsedBody() ?? [];
$redirectUrl = is_string($body['url'] ?? null) ? $body['url'] : '';
$params = $request->getMethod() === 'GET'
? $request->getQueryParams()
: ($request->getParsedBody() ?? []);
$redirectUrl = is_string($params['url'] ?? null) ? $params['url'] : '';

$verifier = PkceGenerator::generateVerifier();
$challenge = PkceGenerator::computeChallenge($verifier);
Expand Down
17 changes: 17 additions & 0 deletions src/Factory/LoginServiceFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
use Exception;
use Horde\Core\Config\ConfigLoader;
use Horde\Core\Service\OAuthProviderConfigRepository;
use Horde\Core\Service\OidcPreLogoutHandler;
use Horde\Core\Session\HordeSession;
use Horde\Core\Session\SessionAccess;
use Horde\Core\Session\SessionConfig;
use Horde\Core\Session\SessionLifecycle;
Expand Down Expand Up @@ -69,6 +71,20 @@ public function create(Injector $injector): LoginService
// ConfigLoader is unavailable (test fixtures, partial DI setups).
$conf = $this->resolveConf($injector);

// Register OidcPreLogoutHandler whenever OAuth/OIDC providers are
// configured, independently of the Horde auth driver — a user may
// authenticate via LDAP/SQL/etc. while still holding OAuth tokens
// (e.g. for XOAUTH2 IMAP access) that should be revoked/SLO'd on
// logout.
$preLogoutHandlers = [];
try {
if ($providerConfig->listEnabled() !== []) {
$preLogoutHandlers[] = $injector->getInstance(OidcPreLogoutHandler::class);
}
} catch (Exception $e) {
$logger->warning('Could not resolve OidcPreLogoutHandler: ' . $e->getMessage());
}

return new LoginService(
$registry,
$logger,
Expand All @@ -83,6 +99,7 @@ public function create(Injector $injector): LoginService
$sessionConfig,
$notification,
$conf,
$preLogoutHandlers,
);
}

Expand Down
39 changes: 39 additions & 0 deletions src/Factory/OAuthTokenRepositoryFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @package Horde
* @author Ralf Lang <ralf.lang@ralf-lang.de>
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
*/

namespace Horde\Horde\Factory;

use Horde\Core\Service\OAuthTokenRepository;
use Horde\Db\Adapter;
use Horde\Horde\Service\SqlOAuthTokenRepository;
use Horde\Injector\Injector;
use Horde\Secret\SecretManager;

/**
* Factory for OAuthTokenRepository.
*
* Returns SqlOAuthTokenRepository backed by the Horde DB adapter.
*/
class OAuthTokenRepositoryFactory
{
public function create(Injector $injector): OAuthTokenRepository
{
return new SqlOAuthTokenRepository(
db: $injector->getInstance(Adapter::class),
secret: $injector->getInstance(SecretManager::class),
);
}
}
32 changes: 28 additions & 4 deletions src/Service/LoginService.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use Horde\Core\Assets\ResponsiveAssets;
use Horde\Core\Config\RegistryState;
use Horde\Core\Service\OAuthProviderConfigRepository;
use Horde\Core\Service\PreLogoutHandlerInterface;
use Horde\Core\Session\HordeSession;
use Horde\Core\Session\SessionAccess;
use Horde\Exception\HordeThrowable;
Expand Down Expand Up @@ -74,6 +75,7 @@ public function __construct(
private readonly SessionConfig $sessionConfig,
private readonly Horde_Notification_Handler $notification,
private readonly array $conf,
private readonly array $preLogoutHandlers = [],
) {}

/**
Expand Down Expand Up @@ -444,6 +446,24 @@ public function performLogout(LogoutRequest $request): array
);
}

// Run pre-logout handlers before the session is destroyed.
// A failing handler must never prevent the logout from completing.
$postLogoutRedirect = null;
if ($currentUser) {
foreach ($this->preLogoutHandlers as $handler) {
try {
$data = $handler->onBeforeLogout($currentUser, $request->reason);
if (!empty($data['redirect']) && $postLogoutRedirect === null) {
$postLogoutRedirect = $data['redirect'];
}
} catch (Throwable $e) {
$this->logger->warning(
'PreLogoutHandler ' . $handler::class . ' failed: ' . $e->getMessage()
);
}
}
}

// Clear authentication
$this->registry->clearAuth();

Expand Down Expand Up @@ -474,11 +494,15 @@ public function performLogout(LogoutRequest $request): array
// Ignore - theme will use system default
}

// Check redirect_on_logout config
if ($request->reason === Horde_Auth::REASON_LOGOUT
// Handler redirect takes priority over redirect_on_logout config
if ($postLogoutRedirect === null
&& $request->reason === Horde_Auth::REASON_LOGOUT
&& !empty($this->conf['auth']['redirect_on_logout'])) {
$logoutUrl = $this->conf['auth']['redirect_on_logout'];
return ['redirect' => $logoutUrl, 'reason' => $request->reason];
$postLogoutRedirect = $this->conf['auth']['redirect_on_logout'];
}

if ($postLogoutRedirect !== null) {
return ['redirect' => $postLogoutRedirect, 'reason' => $request->reason];
}

// Default redirect to login page
Expand Down
Loading