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
41 changes: 41 additions & 0 deletions config/conf.xml
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,22 @@ cookie policy. See session configuration options.">$_SERVER['SERVER_NAME'] ?? $_
</configswitch>
</configsection>
</case>
<case name="oidc" desc="OpenID Connect (OIDC) / OAuth2 Authentication.
Users authenticate via an external identity provider (e.g. Apereo CAS,
Keycloak). Requires at least one provider to be configured under
Administration > Authentication > OAuth Providers with a Client ID and
Client Secret, and token storage to be configured in the OAuth / OIDC
Tokens tab.">
<configsection name="params">
<configstring name="redirect_provider" required="false"
desc="Provider ID to redirect to automatically on login. Must match
the 'Provider ID' field of an enabled provider configured under
Administration &gt; Authentication &gt; OAuth Providers.
Leave empty to show the login page with all configured provider
buttons. If multiple providers are configured, set this to the ID
of the one that should handle automatic login (e.g. 'cas-upjv')." />
</configsection>
</case>
</configswitch>

<configsection name="params">
Expand Down Expand Up @@ -2605,5 +2621,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,
// ];
// }


}
14 changes: 13 additions & 1 deletion config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,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 @@ -583,3 +583,15 @@
])
->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();
49 changes: 29 additions & 20 deletions login.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ function _addAnchor($url, $type, $vars, $url_anchor = null)
}

if ($logout_reason) {
$postLogoutData = ['redirect' => null];
if ($is_auth) {
try {
$tokenService = $injector->getInstance(Token::class);
Expand All @@ -144,36 +145,28 @@ function _addAnchor($url, $type, $vars, $url_anchor = null)
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 Expand Up @@ -382,6 +375,22 @@ function _addAnchor($url, $type, $vars, $url_anchor = null)
}
}

// OIDC auth driver: auto-redirect to the configured identity provider.
// When auth.driver = oidc and auth.params.redirect_provider is set,
// build alternate_login automatically so login.php redirects to the
// provider without showing the username/password form.
if (empty($conf['auth']['alternate_login'])
&& strcasecmp($conf['auth']['driver'] ?? '', 'oidc') === 0
&& !empty($conf['auth']['params']['redirect_provider'])
&& (!($logout_reason === Horde_Auth::REASON_BADLOGIN
|| $logout_reason === Horde_Auth::REASON_FAILED
|| $logout_reason === Horde_Auth::REASON_LOCKED))) {
$conf['auth']['alternate_login'] =
rtrim($registry->get('webroot', 'horde'), '/')
. '/auth/oauth/login/'
. rawurlencode($conf['auth']['params']['redirect_provider']);
}

/* Redirect the user if an alternate login page has been specified. */
if (!empty($conf['auth']['alternate_login'])) {
$url = new Horde_Url($conf['auth']['alternate_login'], true);
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
14 changes: 14 additions & 0 deletions src/Factory/LoginServiceFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
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\SessionConfig;
use Horde\Core\Session\SessionLifecycle;
Expand Down Expand Up @@ -68,6 +69,18 @@ public function create(Injector $injector): LoginService
// ConfigLoader is unavailable (test fixtures, partial DI setups).
$conf = $this->resolveConf($injector);

// Collect pre-logout handlers. Handlers are registered conditionally
// based on the configured auth driver. Additional handlers can be
// added here as new auth drivers are introduced.
$preLogoutHandlers = [];
if (strcasecmp($conf['auth']['driver'] ?? '', 'oidc') === 0) {
try {
$preLogoutHandlers[] = $injector->getInstance(OidcPreLogoutHandler::class);
} catch (Exception $e) {
$logger->warning('Could not resolve OidcPreLogoutHandler: ' . $e->getMessage());
}
}

return new LoginService(
$registry,
$logger,
Expand All @@ -82,6 +95,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),
);
}
}
Loading