+$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();
diff --git a/login.php b/login.php
index 67f844bf..016f8469 100644
--- a/login.php
+++ b/login.php
@@ -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);
@@ -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']);
@@ -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);
diff --git a/migration/3_horde_oauth_providers_oidc_fields.php b/migration/3_horde_oauth_providers_oidc_fields.php
new file mode 100644
index 00000000..5fef790b
--- /dev/null
+++ b/migration/3_horde_oauth_providers_oidc_fields.php
@@ -0,0 +1,49 @@
+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);
+ }
+ }
+}
diff --git a/src/Admin/OAuthProviderController.php b/src/Admin/OAuthProviderController.php
index 054bce02..614e3f66 100644
--- a/src/Admin/OAuthProviderController.php
+++ b/src/Admin/OAuthProviderController.php
@@ -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 = [];
@@ -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);
}
diff --git a/src/Auth/OAuthLoginController.php b/src/Auth/OAuthLoginController.php
index 91654e29..fa8e6f0f 100644
--- a/src/Auth/OAuthLoginController.php
+++ b/src/Auth/OAuthLoginController.php
@@ -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);
diff --git a/src/Factory/LoginServiceFactory.php b/src/Factory/LoginServiceFactory.php
index d1af5a9d..7b1e0a27 100644
--- a/src/Factory/LoginServiceFactory.php
+++ b/src/Factory/LoginServiceFactory.php
@@ -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;
@@ -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,
@@ -82,6 +95,7 @@ public function create(Injector $injector): LoginService
$sessionConfig,
$notification,
$conf,
+ $preLogoutHandlers,
);
}
diff --git a/src/Factory/OAuthTokenRepositoryFactory.php b/src/Factory/OAuthTokenRepositoryFactory.php
new file mode 100644
index 00000000..e00b4377
--- /dev/null
+++ b/src/Factory/OAuthTokenRepositoryFactory.php
@@ -0,0 +1,39 @@
+
+ * @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),
+ );
+ }
+}
diff --git a/src/Service/LoginService.php b/src/Service/LoginService.php
index ae8a290e..a27ed6c4 100644
--- a/src/Service/LoginService.php
+++ b/src/Service/LoginService.php
@@ -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\SessionConfig;
use Horde\Core\Session\SessionLifecycle;
@@ -71,6 +72,7 @@ public function __construct(
private readonly SessionConfig $sessionConfig,
private readonly Horde_Notification_Handler $notification,
private readonly array $conf,
+ private readonly array $preLogoutHandlers = [],
) {}
/**
@@ -416,6 +418,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();
@@ -423,13 +443,6 @@ public function performLogout(LogoutRequest $request): array
$this->notification->detach('status');
$this->notification->attach('status');
- // Check redirect_on_logout config
- if ($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];
- }
-
// Setup fresh anonymous session via the modern lifecycle.
// SessionLifecycle::setup() is idempotent and replaces the
// legacy shim's $GLOBALS['session']->setup() path that this
@@ -453,6 +466,17 @@ public function performLogout(LogoutRequest $request): array
// Ignore - theme will use system default
}
+ // 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'])) {
+ $postLogoutRedirect = $this->conf['auth']['redirect_on_logout'];
+ }
+
+ if ($postLogoutRedirect !== null) {
+ return ['redirect' => $postLogoutRedirect, 'reason' => $request->reason];
+ }
+
// Default redirect to login page
$webroot = $this->registry->get('webroot', 'horde');
$redirect = $webroot . '/auth/login?logout_reason=logout';
diff --git a/src/Settings/OAuthAccountController.php b/src/Settings/OAuthAccountController.php
index 7d031883..9d476cc0 100644
--- a/src/Settings/OAuthAccountController.php
+++ b/src/Settings/OAuthAccountController.php
@@ -274,6 +274,12 @@ private function handleLoginCallback(
$email = $userinfo['email'] ?? null;
$displayName = $userinfo['name'] ?? $userinfo['display_name'] ?? $userinfo['login'] ?? null;
+ // Resolve a local Horde username from the userinfo claims.
+ // Priority: preferred_username → uid → login → left part of email.
+ // This ensures a human-readable Horde username instead of the
+ // opaque identity UUID, regardless of whether a local link exists.
+ $preferredUsername = $this->resolveLocalUsername($userinfo);
+
if ($externalId === '') {
return $this->redirect($loginUrl . '?error=failed');
}
@@ -296,7 +302,19 @@ private function handleLoginCallback(
}
}
+ // No local link yet: create one from the preferred username so that
+ // subsequent logins resolve to a human-readable Horde username
+ // instead of the identity UUID.
+ if ($localUsername === null && $preferredUsername !== null) {
+ $this->identityLinkService->coupleLocalUser($identity->id, $preferredUsername);
+ $localUsername = $preferredUsername;
+ }
+
+ // Store tokens so IMP/Ingo hooks can retrieve them via OAuthTokenService.
+ // handleLoginCallback does not link an account but still needs tokens
+ // available for XOAUTH2 authentication in mail clients.
$authId = $localUsername ?? $identity->id;
+ $this->tokenService->store($authId, $providerId, $tokenSet);
$this->registry->setAuth($authId, []);
if ($this->identityService->getAll($authId) === []) {
@@ -307,11 +325,17 @@ private function handleLoginCallback(
]);
}
+ // Filter login.php as redirect destination — it loops back to the portal
+ if ($redirectUrl !== '' && str_contains($redirectUrl, '/login.php')) {
+ $redirectUrl = '';
+ }
+
if ($redirectUrl !== '') {
return $this->redirect($redirectUrl);
}
- return $this->redirect((string) $this->registry->getServiceLink('portal'));
+ return $this->redirect(rtrim($this->registry->get('webroot', 'horde'), '/') . '/index.php');
+
} catch (Throwable $e) {
error_log('OAuthLoginCallback failed: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
return $this->redirect($loginUrl . '?error=failed');
@@ -401,7 +425,10 @@ private function handleAccountLinkCallback(
}
if ($flowData->redirectUrl !== '') {
- return $this->redirect($flowData->redirectUrl);
+ if (str_contains($flowData->redirectUrl, '/login.php')) {
+ $initialPage = $this->registry->getInitialPage('horde');
+ return $this->redirect($initialPage ?? (string) $this->registry->getServiceLink('portal'));
+ }
}
return $this->redirect($baseUrl . '/');
@@ -488,4 +515,39 @@ private function getBaseUrl(): string
{
return rtrim($this->registry->get('webroot', 'horde'), '/') . '/settings/oauth';
}
+
+
+ /**
+ * Resolve a local Horde username from OIDC userinfo claims.
+ *
+ * Priority:
+ * 1. preferred_username (standard OIDC claim, used by CAS, Keycloak…)
+ * 2. uid (LDAP-style claim, common in university IdPs)
+ * 3. login (GitHub, GitLab)
+ * 4. left part of email (last resort, strips domain)
+ *
+ * Returns null if none of the above are available, in which case the
+ * caller falls back to the identity UUID.
+ */
+ private function resolveLocalUsername(array $userinfo): ?string
+ {
+ foreach (['preferred_username', 'uid', 'login', 'sub', 'id'] as $claim) {
+ $value = trim((string) ($userinfo[$claim] ?? ''));
+ if ($value !== '') {
+ // Strip domain suffix if present (e.g. "jdoe@example.com" → "jdoe")
+ return strstr($value, '@', true) ?: $value;
+ }
+ }
+
+ // Last resort: left part of email
+ $email = trim((string) ($userinfo['email'] ?? ''));
+ if ($email !== '') {
+ $local = strstr($email, '@', true);
+ if ($local !== false && $local !== '') {
+ return $local;
+ }
+ }
+
+ return null;
+ }
}
diff --git a/templates/admin/oauthprovider/edit-oauth2.html.php b/templates/admin/oauthprovider/edit-oauth2.html.php
index f0b96ef2..2e807afd 100644
--- a/templates/admin/oauthprovider/edit-oauth2.html.php
+++ b/templates/admin/oauthprovider/edit-oauth2.html.php
@@ -126,6 +126,54 @@
+provider['type'] === 'oidc'): ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+