diff --git a/doc/OIDC.md b/doc/OIDC.md new file mode 100644 index 00000000..b14e7497 --- /dev/null +++ b/doc/OIDC.md @@ -0,0 +1,332 @@ +# OIDC / OAuth2 Authentication + +Horde supports authentication via an external OpenID Connect (OIDC) +identity provider such as Apereo CAS or Keycloak. Users are redirected +to the provider's login page; Horde never handles passwords directly. +Access and refresh tokens are stored server-side and used for XOAUTH2 +authentication against IMAP and SMTP. + +## Requirements + +- A configured OIDC/OAuth2 identity provider with: + - An application registered with a Client ID and Client Secret + - The Horde callback URL whitelisted as a redirect URI +- SQL database access (required for token storage in production) +- PHP `openssl` extension (for JWT verification) + +## Architecture + +``` +Browser → login.php → /auth/oauth/login/:providerId → IdP login page + ← callback ← /settings/oauth/callback ← IdP redirect +``` + +After the callback, `OAuthAccountController` stores the token set and +calls `setAuth()`. Subsequent requests are validated by +`Horde_Core_Auth_Oidc::validateAuth()`, which checks that tokens are +still present in the repository. IMAP and SMTP connections use XOAUTH2 +via hooks. + +On logout, `OidcPreLogoutHandler` runs before `clearAuth()`, optionally +revoking tokens at the provider and redirecting to the IdP's +`end_session_endpoint` for Single Log-Out (SLO). + +Back-channel logout (RFC 9470) is supported via a dedicated endpoint +that receives a signed `logout_token` JWT from the IdP and removes the +affected user's tokens, causing session invalidation on the next request. + +## Installation + +### 1. Database migration + +Run the Horde database migration tool after updating the codebase: + +```bash +/path/to/horde/vendor/bin/horde-db-migrate horde up +``` + +This creates the `horde_oauth_tokens` table for token storage and adds +the OIDC-specific columns to `horde_oauth_providers`. + +### 2. Auth driver + +In `var/config/horde/conf.php`: + +```php +$conf['auth']['driver'] = 'oidc'; + +// Optional: auto-redirect to a specific provider on login. +// Must match the Provider ID configured in the admin UI. +$conf['auth']['params']['redirect_provider'] = 'cas-univ'; +``` + +When `redirect_provider` is set, `login.php` skips the username/password +form and redirects directly to the provider. + +### 3. Token storage + +In the Horde admin UI under **Administration → Configuration → OAuth / +OIDC Tokens**, set the token driver to **SQL Database**. This is +required for any multi-worker or production deployment. + +Alternatively, in `var/config/horde/conf.php`: + +```php +$conf['oauth']['token_driver'] = 'sql'; +// Uses the default Horde DB connection. +``` + +### 4. Provider setup + +Go to **Administration → Authentication → OAuth Providers** and create +a new provider of type `oidc`. + +Enter the Issuer URL and click **Auto-discover endpoints from issuer** — +Horde will populate all endpoints from the provider's +`.well-known/openid-configuration` document. + +Then fill in the **Credentials** section: + +| Field | Value | +|---|---| +| Client ID | From your IdP's application console | +| Client Secret | From your IdP's application console | +| Default Scopes | `openid email profile` (adjust to your IdP) | + +Copy the **Redirect URI** shown on the form into your IdP's application +configuration as the allowed callback URL. + +#### OIDC-specific fields + +| Field | Description | +|---|---| +| Logout Strategy | `local`, `slo` or `revoke_and_slo` (see below) | +| End Session Endpoint | Leave empty to use auto-discovery | +| Post-Logout Redirect URI | Where the IdP redirects after SLO | +| Back-Channel Username Claim | JWT claim identifying the user (default: `sub`) | +| Use email as XOAUTH2 username | Append a domain to the Horde username | +| XOAUTH2 Domain | Domain to append (e.g. `example.com`) | + +### 5. Hooks + +Activate the OIDC hooks by copying the relevant sections from the +`.dist` files to your deployed hooks files. + +#### IMAP (`var/config/imp/hooks.php`) + +Uncomment `imap_preauthenticate` to inject a XOAUTH2 token into each +IMAP connection: + +```php +public function imap_preauthenticate(array $credentials): array +{ + global $injector; + $username = $injector->getInstance('Horde_Registry')->getAuth(); + if (!$username) { + return $credentials; + } + $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) { + return $credentials; + } + $accessToken = \Horde\Core\Service\OidcHookHelper::getValidAccessToken( + $username, $row, $tokenService, $injector + ); + if ($accessToken === null) { + return $credentials; + } + $xoauth2User = \Horde\Core\Service\OidcHookHelper::xoauth2Username($username, $row); + $credentials['password'] = new Horde_Imap_Client_Password_Xoauth2( + $xoauth2User, $accessToken + ); + return $credentials; +} +``` + +Also uncomment `dynamic_prefs` to proactively refresh tokens before +they expire during a session (recommended): + +```php +public function dynamic_prefs() +{ + 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 []; +} +``` + +#### SMTP (`var/config/horde/hooks.php`) + +Uncomment `smtp_credentials`: + +```php +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, + ]; +} +``` + +#### Sieve / Ingo (`var/config/ingo/hooks.php`) + +Uncomment the `timsieved` case in `transport_credentials`. See +`horde/ingo/config/hooks.php.dist` for the full example. + +## Logout strategies + +The logout strategy is configured per provider in the admin UI under +**Logout Strategy**. + +| Strategy | Behaviour | +|---|---| +| `local` | Tokens are removed from local storage only. The IdP session remains active. | +| `slo` | Tokens are removed locally, then the browser is redirected to the IdP's `end_session_endpoint`. | +| `revoke_and_slo` | Tokens are revoked at the provider's revocation endpoint, then SLO redirect. | + +The SLO redirect URL is resolved in order: +1. `end_session_endpoint` field on the provider +2. Auto-discovery from `{issuer}/.well-known/openid-configuration` + +Configure `post_logout_redirect_uri` to control where the IdP redirects +the browser after SLO. Typically this should be your Horde login page: + +``` +https://webmail.example.org/horde/login.php +``` + +## Back-channel logout (RFC 9470) + +Horde implements OpenID Connect Back-Channel Logout 1.0. When the IdP +terminates a user's session (e.g. because they logged out of another +application), it sends a signed `logout_token` JWT to: + +``` +POST /horde/auth/oidc/backchannel-logout +``` + +Horde validates the token (signature, issuer, audience, expiry, `jti` +replay protection, event URI) and removes the user's tokens from the +repository. On the user's next request, `validateAuth()` finds no tokens +and terminates the Horde session. + +Configure this URL in your IdP as the back-channel logout URL. For +Apereo CAS: + +``` +cas.properties: + cas.logout.enabled=true + cas.slo.disabled=false +``` + +And in the registered service: +```json +{ + "logoutType": "BACK_CHANNEL", + "logoutUrl": "https://webmail.example.org/horde/auth/oidc/backchannel-logout" +} +``` + +### JWT validation + +| Check | Detail | +|---|---| +| Signature | RS256 or ES256 via provider JWKS | +| `iss` | Must match configured provider issuer | +| `aud` | Must match configured client ID | +| `exp` | Must not be expired (±5 min clock skew) | +| `iat` | Must be recent (±5 min) | +| `jti` | Must not have been seen before (1h replay cache) | +| `events` | Must contain the BCL event URI | +| `nonce` | Must be absent | + +JWKS keys are cached for one hour. + +## Troubleshooting + +**Users are redirected to login immediately after authenticating** + +Check that the SQL token driver is configured and the migration has run. +If `validateAuth()` finds no tokens it terminates the session. + +**Important:** The `Builtin` session handler is incompatible with the +modern `HordeSession` stack — sessions are written in memory but never +persisted to disk, causing every request to appear unauthenticated. +Set `$conf['sessionhandler']['type'] = 'Sql'` in `conf.php` for any +production deployment. + +**IMAP authentication fails** + +Verify that the IMAP server supports XOAUTH2 and that the +`imap_preauthenticate` hook is active. Check that the access token +contains the claim expected by the IMAP server's SASL plugin (often +`email` or `sub` — configure `oauth2_user_claim` in the SASL plugin +accordingly). + +**SMTP authentication fails with "User claim not found"** + +The SASL XOAUTH2 plugin on the SMTP server is looking for a claim +(typically `email`) that is absent from the access token. Set +`oauth2_user_claim: sub` (or the appropriate claim) in the SASL +configuration on the SMTP server. + +**Back-channel logout has no effect** + +Look for `[OidcBCL]` entries in the Horde log. The endpoint must be +reachable without authentication — verify the route middleware stack +does not include `RedirectToLogin`. + +**SLO redirect does not happen** + +Ensure the provider's `end_session_endpoint` is either configured +explicitly or discoverable via `.well-known/openid-configuration` from +the Horde server. Check for `[OidcPreLogout]` entries in the PHP error +log. diff --git a/lib/Horde/Core/Auth/Oidc.php b/lib/Horde/Core/Auth/Oidc.php new file mode 100644 index 00000000..a385c014 --- /dev/null +++ b/lib/Horde/Core/Auth/Oidc.php @@ -0,0 +1,150 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class Horde_Core_Auth_Oidc extends Horde_Auth_Base +{ + /** + * Driver capabilities. + */ + protected $_capabilities = [ + 'transparent' => true, + 'list' => true, + 'logout' => true, + ]; + + /** + * Constructor + */ + public function __construct(array $params = []) + { + parent::__construct($params); + + // Disable user listing if LDAP is not configured. + if (empty($GLOBALS['conf']['ldap']['hostspec'])) { + $this->_capabilities['list'] = false; + } + } + + /** + * Validate authentication on each Horde request. + * + * Returns true if the user has OAuth2 tokens stored, false otherwise. + * Safe failure policy: any unexpected error returns true to avoid + * logging the user out due to a transient infrastructure failure. + * + * @return bool True if the session should be considered valid. + */ + public function validateAuth(): bool + { + global $injector; + + $username = $GLOBALS['registry']->getAuth(); + if (!$username) { + return true; + } + + try { + $tokenService = $injector->getInstance(\Horde\Core\Service\OAuthTokenService::class); + $providerConfig = $injector->getInstance(\Horde\Core\Service\OAuthProviderConfigRepository::class); + } catch (Exception $e) { + // Infrastructure unavailable — allow session to continue. + return true; + } + + foreach ($providerConfig->listEnabled() as $row) { + if ($tokenService->hasTokens($username, $row['provider_id'])) { + return true; + } + } + + $this->setError(Horde_Auth::REASON_SESSION); + return false; + } + + /** + * Logout — no-op. + * + * Token revocation and SLO are handled by OidcPreLogoutHandler, + * which runs before clearAuth() inside LoginService::performLogout(). + * + * @return bool + */ + public function logout(): bool + { + return true; + } + + /** + * Authentication — not used directly. + * + * Authentication is handled by OAuthAccountController via + * the /auth/oauth/login flow. + */ + protected function _authenticate($userId, $credentials): void + { + throw new Horde_Auth_Exception('Direct authentication not supported; use OIDC login flow.'); + } + + /** + * List all users from LDAP. + * + * Uses $conf['ldap']['user'] configuration (basedn, uid, filter). + * + * @param bool $sort Sort the user list. + * @return array Array of user IDs. + * @throws Horde_Auth_Exception + */ + public function listUsers($sort = false): array + { + global $conf; + + $ldapParams = $conf['ldap']['user'] ?? null; + if (empty($ldapParams['basedn']) || empty($ldapParams['uid'])) { + throw new Horde_Auth_Exception('LDAP user configuration missing (ldap.user.basedn or ldap.user.uid)'); + } + + try { + $ldap = $GLOBALS['injector'] + ->getInstance('Horde_Core_Factory_Ldap') + ->create('horde', 'ldap'); + + $uid = $ldapParams['uid']; + $filter = !empty($ldapParams['filter']) + ? Horde_Ldap_Filter::build(['filter' => $ldapParams['filter']]) + : Horde_Ldap_Filter::create('objectClass', 'present'); + + $search = $ldap->search( + $ldapParams['basedn'], + $filter, + ['attributes' => [$uid]] + ); + + $users = []; + foreach ($search as $entry) { + if ($entry->exists($uid)) { + $users[] = $entry->getValue($uid, 'single'); + } + } + + if ($sort) { + sort($users); + } + + return $users; + + } catch (Horde_Ldap_Exception $e) { + throw new Horde_Auth_Exception('LDAP listUsers failed: ' . $e->getMessage()); + } + } +} diff --git a/lib/Horde/Core/Factory/Auth.php b/lib/Horde/Core/Factory/Auth.php index 71997cf3..7c63fd4c 100644 --- a/lib/Horde/Core/Factory/Auth.php +++ b/lib/Horde/Core/Factory/Auth.php @@ -83,6 +83,8 @@ protected function _create($driver, $orig_params = null) $driver = 'Horde_Core_Auth_Msad'; } elseif (strcasecmp($driver, 'shibboleth') === 0) { $driver = 'Horde_Core_Auth_Shibboleth'; + } elseif (strcasecmp($driver, 'oidc') === 0) { + $driver = 'Horde_Core_Auth_Oidc'; } elseif (strcasecmp($driver, 'imsp') === 0) { $driver = 'Horde_Core_Auth_Imsp'; } elseif (strcasecmp($driver, 'x509') === 0) { diff --git a/lib/Horde/Registry.php b/lib/Horde/Registry.php index 8d9b2365..fd8611db 100644 --- a/lib/Horde/Registry.php +++ b/lib/Horde/Registry.php @@ -2838,6 +2838,7 @@ public function checkExistingAuth($app = 'horde') if (!empty($conf['session']['max_time']) && (($conf['session']['max_time'] + $session->begin) < time())) { + error_log('[SessionMax] max_time=' . $conf['session']['max_time'] . ' begin=' . $session->begin . ' now=' . time() . ' diff=' . ($conf['session']['max_time'] + $session->begin - time())); $injector->getInstance('Horde_Core_Factory_Auth')->create() ->setError(Horde_Core_Auth_Application::REASON_SESSIONMAXTIME); return false; diff --git a/src/Auth/OidcBackchannelLogoutController.php b/src/Auth/OidcBackchannelLogoutController.php new file mode 100644 index 00000000..7032d2ad --- /dev/null +++ b/src/Auth/OidcBackchannelLogoutController.php @@ -0,0 +1,292 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Auth; + +use Horde\Core\Service\OAuthProviderConfigRepository; +use Horde\Core\Service\OAuthTokenService; +use Horde\Jwt\Exception\InvalidTokenException; +use Horde\Jwt\Key\Jwk; +use Horde\Jwt\Key\PublicKey; +use Horde\Jwt\TokenDecoder; +use Horde\Jwt\Verifier\Es256Verifier; +use Horde\Jwt\Verifier\Rs256Verifier; +use Horde\Jwt\Verifier\VerifierInterface; +use Horde_Cache; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Psr\Log\LoggerInterface; + +/** + * OidcBackchannelLogoutController + * + * Receives a signed logout_token JWT from the identity provider + * (RFC 9470 / OpenID Connect Back-Channel Logout 1.0), validates it, + * identifies the affected user and removes their tokens via OAuthTokenService. + * + * Route: POST /auth/oidc/backchannel-logout (HordeAuthType: NONE) + * + * Provider configuration (Apereo CAS example): + * logoutType : BACK_CHANNEL + * logoutUrl : https://webmail.example.org/horde/auth/oidc/backchannel-logout + * + * The endpoint always returns HTTP 200 to prevent the provider from + * retrying failed deliveries. Errors are logged internally. + * + * JWT validation performed: + * - Signature verified via horde/Jwt (RS256 or ES256) + * - iss verified against configured provider URL + * - aud verified against our client_id + * - exp verified (via TokenDecoder, with CLOCK_SKEW leeway) + * - iat must be recent (within CLOCK_SKEW seconds) + * - jti must not have been seen before (replay protection, 1h cache) + * - events must contain the back-channel logout event URI + * - nonce must be absent (spec requirement) + * + * Supported algorithms: RS256, ES256. + */ +class OidcBackchannelLogoutController implements RequestHandlerInterface +{ + private const CLOCK_SKEW = 300; + private const JTI_TTL = 3600; + private const BCL_EVENT_URI = 'http://schemas.openid.net/event/backchannel-logout'; + + public function __construct( + private readonly OAuthProviderConfigRepository $providerConfig, + private readonly OAuthTokenService $tokenService, + private readonly Horde_Cache $cache, + private readonly ResponseFactoryInterface $responseFactory, + private readonly LoggerInterface $logger, + ) {} + + public function handle(ServerRequestInterface $request): ResponseInterface + { + // Spec: always return 200, even on error + $ok = $this->responseFactory->createResponse(200); + + if ($request->getMethod() !== 'POST') { + $this->logger->warning('[OidcBCL] Non-POST request rejected'); + return $ok; + } + + $body = $request->getParsedBody() ?? []; + $logoutToken = is_array($body) ? ($body['logout_token'] ?? null) : null; + + if (empty($logoutToken)) { + $this->logger->warning('[OidcBCL] Missing logout_token in POST body'); + return $ok; + } + + $this->logger->info('[OidcBCL] Back-channel logout request received'); + + // ── Peek at header/payload to resolve provider before verification ──── + + $parts = explode('.', $logoutToken); + if (count($parts) !== 3) { + $this->logger->error('[OidcBCL] logout_token is not a valid JWT'); + return $ok; + } + + $header = json_decode($this->base64url($parts[0]), true); + $payload = json_decode($this->base64url($parts[1]), true); + + if (!is_array($header) || !is_array($payload)) { + $this->logger->error('[OidcBCL] logout_token: invalid JSON in header or payload'); + return $ok; + } + + // ── Resolve provider by iss ─────────────────────────────────────────── + + $issuer = rtrim((string) ($payload['iss'] ?? ''), '/'); + if ($issuer === '') { + $this->logger->error('[OidcBCL] iss claim missing'); + return $ok; + } + + $row = $this->findProviderByIssuer($issuer); + if ($row === null) { + $this->logger->error("[OidcBCL] No provider configured for issuer: $issuer"); + return $ok; + } + + // ── Build verifier from provider JWKS ───────────────────────────────── + + $kid = $header['kid'] ?? null; + $alg = strtoupper($header['alg'] ?? 'RS256'); + $verifier = $this->buildVerifier($row, $kid, $alg); + + if ($verifier === null) { + $this->logger->error("[OidcBCL] Could not build verifier for alg=$alg"); + return $ok; + } + + // ── Full JWT verification via horde/Jwt ─────────────────────────────── + + $decoder = new TokenDecoder(); + try { + $verified = $decoder->decode($logoutToken, $verifier, [ + 'leeway' => self::CLOCK_SKEW, + 'verify_iss' => rtrim($row['issuer'] ?? $row['url'] ?? '', '/'), + 'verify_aud' => $row['client_id'] ?? '', + ]); + } catch (InvalidTokenException $e) { + $this->logger->error('[OidcBCL] JWT verification failed: ' . $e->getMessage()); + return $ok; + } + + $this->logger->info('[OidcBCL] logout_token signature verified'); + + // ── Additional BCL-specific claim validation ─────────────────────────── + + $iat = $verified->getClaim('iat'); + if (!is_int($iat) || abs(time() - $iat) > self::CLOCK_SKEW) { + $this->logger->error('[OidcBCL] iat missing or outside clock skew window'); + return $ok; + } + + $jti = $verified->getClaim('jti'); + if (empty($jti)) { + $this->logger->error('[OidcBCL] jti claim missing'); + return $ok; + } + + $jtiKey = 'oidc_bcl_jti_' . hash('sha256', (string) $jti); + if ($this->cache->get($jtiKey, self::JTI_TTL) !== false) { + $this->logger->warning("[OidcBCL] Replayed jti rejected: $jti"); + return $ok; + } + $this->cache->set($jtiKey, '1', self::JTI_TTL); + + $events = $verified->getClaim('events'); + if (!is_array($events) || !array_key_exists(self::BCL_EVENT_URI, $events)) { + $this->logger->error('[OidcBCL] events claim missing or invalid'); + return $ok; + } + + if ($verified->hasClaim('nonce')) { + $this->logger->error('[OidcBCL] logout_token must not contain a nonce claim'); + return $ok; + } + + // ── Identify user and clear tokens ──────────────────────────────────── + + $usernameClaim = $row['backchannel_username_claim'] ?? 'sub'; + $uid = (string) ($verified->getClaim($usernameClaim) ?? $verified->getClaim('sub') ?? ''); + + if ($uid === '') { + $this->logger->notice('[OidcBCL] Cannot identify user — no action taken'); + return $ok; + } + + $this->logger->info("[OidcBCL] Back-channel logout for user: $uid (provider: {$row['provider_id']})"); + + try { + $this->tokenService->remove($uid, $row['provider_id']); + $this->logger->info("[OidcBCL] Tokens removed for user: $uid"); + } catch (\Throwable $e) { + $this->logger->error('[OidcBCL] Failed to remove tokens: ' . $e->getMessage()); + } + + return $ok; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function base64url(string $data): string + { + return (string) base64_decode( + strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4) + ); + } + + /** + * @return array|null + */ + private function findProviderByIssuer(string $issuer): ?array + { + foreach ($this->providerConfig->listEnabled() as $row) { + $rowIssuer = rtrim($row['issuer'] ?? $row['url'] ?? '', '/'); + if ($rowIssuer === $issuer) { + return $row; + } + } + return null; + } + + private function buildVerifier(array $row, ?string $kid, string $alg): ?VerifierInterface + { + $publicKey = $this->resolvePublicKey($row, $kid); + if ($publicKey === null) { + return null; + } + + return match ($alg) { + 'RS256' => new Rs256Verifier($publicKey), + 'ES256' => new Es256Verifier($publicKey), + default => null, + }; + } + + private function resolvePublicKey(array $row, ?string $kid): ?PublicKey + { + $cacheKey = 'oidc_jwks_' . ($row['provider_id'] ?? 'default'); + $jwks = null; + + $cached = $this->cache->get($cacheKey, 3600); + if ($cached !== false) { + $jwks = json_decode($cached, true); + } + + if (!is_array($jwks)) { + $jwksUri = $row['jwks_uri'] ?? null; + if ($jwksUri === null) { + $base = rtrim($row['issuer'] ?? $row['url'] ?? '', '/'); + $raw = @file_get_contents($base . '/.well-known/openid-configuration'); + $jwksUri = $raw !== false + ? (json_decode($raw, true)['jwks_uri'] ?? null) + : null; + } + if ($jwksUri === null) { + return null; + } + $raw = @file_get_contents($jwksUri); + if ($raw === false) { + return null; + } + $jwks = json_decode($raw, true); + $this->cache->set($cacheKey, $raw, 3600); + $this->logger->debug('[OidcBCL] JWKS fetched from provider'); + } + + foreach ($jwks['keys'] ?? [] as $key) { + if ($kid !== null && ($key['kid'] ?? null) !== $kid) { + continue; + } + if (($key['use'] ?? 'sig') !== 'sig') { + continue; + } + try { + return Jwk::toPublicKey($key); + } catch (\InvalidArgumentException $e) { + continue; + } + } + + return null; + } +} diff --git a/src/DefaultInjectorBindings.php b/src/DefaultInjectorBindings.php index 7d7b80df..c3d65963 100644 --- a/src/DefaultInjectorBindings.php +++ b/src/DefaultInjectorBindings.php @@ -96,8 +96,10 @@ use Horde\Core\Service\OAuthHttpClientService; use Horde\Core\Service\OAuthProviderConfigRepository; use Horde\Core\Service\OAuthTokenService; +use Horde\Core\Service\OidcPreLogoutHandler; use Horde\Core\Service\PermissionService; use Horde\Core\Service\PrefsService; +use Horde\Core\Service\PreLogoutHandlerInterface; use Horde\Core\Service\VersionCheck\VersionService; use Horde\Core\Uri\RegistryRouteMapperProvider; use Horde\Core\Uri\RouteMapperProvider; @@ -111,6 +113,7 @@ use Horde\Horde\Factory\AuthenticationServiceFactory; use Horde\Horde\Factory\OAuthHttpClientServiceFactory as BaseOAuthHttpClientServiceFactory; use Horde\Horde\Factory\OAuthTokenServiceFactory as BaseOAuthTokenServiceFactory; +use Horde\Horde\Factory\OAuthTokenRepositoryFactory as BaseOAuthTokenRepositoryFactory; use Horde\Horde\Service\AuthenticationService; use Horde\Horde\Service\AuthLinkRepository; use Horde\Identity\IdentityHistoryRepository; @@ -213,6 +216,7 @@ public function register(Injector $injector): void OAuthProviderConfigRepository::class => OAuthProviderConfigRepositoryFactory::class, OAuthFlowStore::class => OAuthFlowStoreFactory::class, OAuthTokenService::class => BaseOAuthTokenServiceFactory::class, + OAuthTokenRepository::class => BaseOAuthTokenRepositoryFactory::class, OAuthHttpClientService::class => BaseOAuthHttpClientServiceFactory::class, IdentityRepository::class => IdentityRepositoryFactory::class, IdentityHistoryRepository::class => IdentityHistoryRepositoryFactory::class, diff --git a/src/Factory/OAuthTokenRepositoryFactory.php b/src/Factory/OAuthTokenRepositoryFactory.php index b584a90f..d701b596 100644 --- a/src/Factory/OAuthTokenRepositoryFactory.php +++ b/src/Factory/OAuthTokenRepositoryFactory.php @@ -17,9 +17,12 @@ namespace Horde\Core\Factory; use Horde\Core\Config\ConfigLoader; +use Horde\Core\Service\HordeDbService; use Horde\Core\Service\NullOAuthTokenRepository; use Horde\Core\Service\OAuthTokenRepository; +use Horde\Horde\Service\SqlOAuthTokenRepository; use Horde\Injector\Injector; +use Horde\Secret\SecretManager; /** * Factory for OAuthTokenRepository. @@ -39,10 +42,13 @@ public function create(Injector $injector): OAuthTokenRepository $loader = $injector->getInstance(ConfigLoader::class); $state = $loader->load('horde'); - $driver = $state->get('oauth.token_driver', 'null'); + $driver = strtolower((string) $state->get('oauth.token_driver', 'null')); return match (strtolower($driver)) { - 'null', '' => new NullOAuthTokenRepository(), + 'sql' => new SqlOAuthTokenRepository( + db: $injector->getInstance(HordeDbService::class)->getAdapter(), + secret: $injector->getInstance(SecretManager::class), + ), default => new NullOAuthTokenRepository(), }; } diff --git a/src/Service/OidcHookHelper.php b/src/Service/OidcHookHelper.php new file mode 100644 index 00000000..52274566 --- /dev/null +++ b/src/Service/OidcHookHelper.php @@ -0,0 +1,167 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde\OAuth\Client\ProviderConfig; +use Horde\OAuth\Client\TokenRefresher; +use Horde\OAuth\Client\TokenSet; +use Horde_Injector; + +/** + * OidcHookHelper + * + * Static helpers for OIDC/XOAUTH2 hooks (imap_preauthenticate, dynamic_prefs, + * transport_auth, smtp_credentials). Centralises token refresh logic so it is + * not duplicated across hooks.php files. + * + * Usage from any hooks.php: + * + * $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) { // not an OAuth2 session + * ... + * } + * + * $accessToken = \Horde\Core\Service\OidcHookHelper::getValidAccessToken( + * $username, $row, $tokenService, $injector + * ); + * + * $xoauth2User = \Horde\Core\Service\OidcHookHelper::xoauth2Username( + * $username, $row + * ); + */ +class OidcHookHelper +{ + /** + * Find the first enabled provider that has stored tokens for $username. + * + * Returns null if this is not an OAuth2 session. + * + * @param string $username + * @param OAuthTokenService $tokenService + * @param OAuthProviderConfigRepository $providerConfig + * @return array|null + */ + public static function findProviderForUser( + string $username, + OAuthTokenService $tokenService, + OAuthProviderConfigRepository $providerConfig, + ): ?array { + foreach ($providerConfig->listEnabled() as $row) { + if ($tokenService->hasTokens($username, $row['provider_id'])) { + return $row; + } + } + return null; + } + + /** + * Return a valid (non-expired) access token for $username, refreshing + * transparently if necessary. + * + * Returns null if no tokens are stored, the token cannot be refreshed, + * or the provider configuration is incomplete. + * + * @param string $username + * @param array $row Provider config row + * @param OAuthTokenService $tokenService + * @param Horde_Injector $injector + */ + public static function getValidAccessToken( + string $username, + array $row, + OAuthTokenService $tokenService, + Horde_Injector $injector, + ): ?string { + try { + $tokenSet = $tokenService->getTokenSet($username, $row['provider_id']); + } catch (\Throwable $e) { + return null; + } + + if (!$tokenSet->isExpired(30)) { + return $tokenSet->accessToken; + } + + if ($tokenSet->refreshToken === null) { + return null; + } + + try { + $config = ProviderConfig::fromArray($row); + $endpoint = $config->tokenEndpoint; + if ($endpoint === null) { + return null; + } + + $refresher = new TokenRefresher( + httpClient: $injector->getInstance('Psr\Http\Client\ClientInterface'), + requestFactory: $injector->getInstance('Psr\Http\Message\RequestFactoryInterface'), + streamFactory: $injector->getInstance('Psr\Http\Message\StreamFactoryInterface'), + tokenEndpoint: $endpoint, + clientId: $row['client_id'] ?? '', + clientSecret: $row['client_secret'] ?? null, + ); + + $newSet = $refresher->refresh($tokenSet->refreshToken, $tokenSet->scope); + + // Preserve refresh token if provider did not issue a new one + if ($newSet->refreshToken === null) { + $newSet = new TokenSet( + accessToken: $newSet->accessToken, + tokenType: $newSet->tokenType, + expiresIn: $newSet->expiresIn, + refreshToken: $tokenSet->refreshToken, + scope: $newSet->scope ?? $tokenSet->scope, + idToken: $newSet->idToken ?? $tokenSet->idToken, + receivedAt: $newSet->receivedAt, + ); + } + + $tokenService->store($username, $row['provider_id'], $newSet); + return $newSet->accessToken; + } catch (\Throwable $e) { + return null; + } + } + + /** + * Resolve the username to use in XOAUTH2 strings. + * + * If the provider config has 'xoauth2_use_email' => true and a + * 'xoauth2_domain' value, the domain is appended to the username + * (e.g. "jdoe" → "jdoe@example.org"). + * + * @param string $username + * @param array $row Provider config row + */ + public static function xoauth2Username(string $username, array $row): string + { + $useEmail = (bool) ($row['xoauth2_use_email'] ?? false); + $domain = (string) ($row['xoauth2_domain'] ?? ''); + + if (!$useEmail || $domain === '' || str_contains($username, '@')) { + return $username; + } + + return $username . '@' . $domain; + } +} diff --git a/src/Service/OidcPreLogoutHandler.php b/src/Service/OidcPreLogoutHandler.php new file mode 100644 index 00000000..890313d1 --- /dev/null +++ b/src/Service/OidcPreLogoutHandler.php @@ -0,0 +1,175 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde\Core\Uri\RouteUrlWriter; +use Horde\OAuth\Client\OAuth2Client; +use Horde\OAuth\Client\ProviderConfig; +use Horde_Auth; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * OidcPreLogoutHandler + * + * Handles OIDC session cleanup before Horde destroys the authenticated + * session. Called by LoginService::performLogout() via PreLogoutHandlerInterface. + * + * Since this handler is called while the session is still active, the + * authenticated username is directly available. + * + * Actions performed: + * 1. Removes tokens from OAuthTokenService + * 2. Optionally revokes them at the provider (logout_type = revoke_and_slo) + * 3. Returns the provider's end_session_endpoint as 'redirect' so that + * LoginService::performLogout() can redirect to it after clearAuth() + * + * Logout strategy is read from the provider config field 'logout_type': + * 'local' — clear tokens locally only + * 'slo' — clear tokens + prepare SLO redirect + * 'revoke_and_slo' — revoke tokens at provider + prepare SLO redirect + */ +class OidcPreLogoutHandler implements PreLogoutHandlerInterface +{ + public function __construct( + private readonly OAuthProviderConfigRepository $providerConfig, + private readonly OAuthTokenService $tokenService, + private readonly RouteUrlWriter $urlWriter, + private readonly ClientInterface $httpClient, + private readonly RequestFactoryInterface $requestFactory, + private readonly StreamFactoryInterface $streamFactory, + private readonly LoggerInterface $logger, + ) {} + + public function onBeforeLogout(string $userId, int $reason): array + { + // Only act on voluntary logouts — not session expiry or forced kicks + if ($reason !== Horde_Auth::REASON_LOGOUT) { + return []; + } + + // Find the provider that has tokens for this user + $row = null; + foreach ($this->providerConfig->listEnabled() as $candidate) { + if ($this->tokenService->hasTokens($userId, $candidate['provider_id'])) { + $row = $candidate; + break; + } + } + + if ($row === null) { + return []; // Not an OAuth2 session + } + + $logoutType = $row['logout_type'] ?? 'local'; + $this->logger->debug("[OidcPreLogout] Strategy '$logoutType' for user $userId"); + + // ── 1. Revoke tokens at provider if requested ───────────────────────── + + if ($logoutType === 'revoke_and_slo') { + $this->revokeTokens($userId, $row); + } + + // ── 2. Remove tokens from local storage ────────────────────────────── + + try { + $this->tokenService->remove($userId, $row['provider_id']); + $this->logger->info("[OidcPreLogout] Tokens removed for user: $userId"); + } catch (Throwable $e) { + $this->logger->warning('[OidcPreLogout] Could not remove tokens: ' . $e->getMessage()); + } + + if ($logoutType === 'slo' || $logoutType === 'revoke_and_slo') { + $sloUrl = $this->resolveSloUrl($row); + if ($sloUrl !== null) { + $postLogout = $row['post_logout_redirect_uri'] + ?? ((string) $this->urlWriter->absoluteUrlFor('HordeServicesPortal')); + $sep = str_contains($sloUrl, '?') ? '&' : '?'; + $target = $sloUrl . $sep + . 'post_logout_redirect_uri=' . urlencode($postLogout) + . '&service=' . urlencode($postLogout); + + return ['redirect' => $target]; + } + } + + return []; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function resolveSloUrl(array $row): ?string + { + if (!empty($row['end_session_endpoint'])) { + return $row['end_session_endpoint']; + } + + $base = rtrim($row['issuer'] ?? $row['url'] ?? '', '/'); + if ($base === '') { + return null; + } + + try { + $raw = @file_get_contents($base . '/.well-known/openid-configuration'); + if ($raw !== false) { + $decoded = json_decode($raw, true); + $endpoint = $decoded['end_session_endpoint'] ?? null; + if ($endpoint) { + return $endpoint; + } + } + } catch (Throwable $e) { + $this->logger->warning('[OidcPreLogout] Discovery failed: ' . $e->getMessage()); + } + + return null; + } + + private function revokeTokens(string $userId, array $row): void + { + try { + $tokenSet = $this->tokenService->getTokenSet($userId, $row['provider_id']); + $config = ProviderConfig::fromArray($row); + + if ($config->revocationEndpoint === null) { + $this->logger->debug('[OidcPreLogout] No revocation endpoint, skipping'); + return; + } + + $client = new OAuth2Client( + provider: $config, + clientId: $row['client_id'] ?? '', + clientSecret: $row['client_secret'] ?? null, + redirectUri: '', + httpClient: $this->httpClient, + requestFactory: $this->requestFactory, + streamFactory: $this->streamFactory, + ); + + if ($tokenSet->refreshToken !== null) { + $client->revokeToken($tokenSet->refreshToken, 'refresh_token'); + } + $client->revokeToken($tokenSet->accessToken, 'access_token'); + $this->logger->info('[OidcPreLogout] Tokens revoked at provider'); + } catch (Throwable $e) { + $this->logger->warning('[OidcPreLogout] Revocation failed (continuing): ' . $e->getMessage()); + } + } +} diff --git a/src/Service/PreLogoutHandlerInterface.php b/src/Service/PreLogoutHandlerInterface.php new file mode 100644 index 00000000..d048917a --- /dev/null +++ b/src/Service/PreLogoutHandlerInterface.php @@ -0,0 +1,48 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +/** + * PreLogoutHandlerInterface + * + * Implement this interface to run code before Horde clears the authenticated + * session during logout. Handlers are called by LoginService::performLogout() + * after CSRF verification and audit logging but BEFORE clearAuth(), so the + * authenticated username is still available. + * + * Typical use cases: + * - Revoke OAuth2/OIDC tokens at the provider (RP-Initiated Logout / SLO) + * - Invalidate active JWTs (blacklist) + * - Close ActiveSync or SAML sessions + * - Release document locks held by the user + * - Write an exact logout timestamp to an external audit log + * + * Handlers must not throw — any exception should be caught internally and + * logged. A failing handler must never prevent the logout from completing. + * + * Register handlers via the DI container (see DefaultInjectorBindings). + */ +interface PreLogoutHandlerInterface +{ + /** + * Called before the authenticated session is destroyed. + * + * @param string $userId The authenticated Horde username + * @param int $reason The logout reason (one of Horde_Auth::REASON_*) + */ + public function onBeforeLogout(string $userId, int $reason): array; +} diff --git a/test/Unit/Service/OidcHookHelperTest.php b/test/Unit/Service/OidcHookHelperTest.php new file mode 100644 index 00000000..02467006 --- /dev/null +++ b/test/Unit/Service/OidcHookHelperTest.php @@ -0,0 +1,221 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Test\Unit\Service; + +use Horde\Core\Service\OAuthProviderConfigRepository; +use Horde\Core\Service\OAuthTokenService; +use Horde\Core\Service\OidcHookHelper; +use Horde\OAuth\Client\TokenSet; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +#[CoversClass(OidcHookHelper::class)] +final class OidcHookHelperTest extends TestCase +{ + private OAuthTokenService $tokenService; + private OAuthProviderConfigRepository $providerConfig; + + protected function setUp(): void + { + $this->tokenService = $this->createStub(OAuthTokenService::class); + $this->providerConfig = $this->createStub(OAuthProviderConfigRepository::class); + } + + // ── findProviderForUser ─────────────────────────────────────────────────── + + public function testFindProviderForUserReturnsNullWhenNoProviders(): void + { + $this->providerConfig->method('listEnabled')->willReturn([]); + + $result = OidcHookHelper::findProviderForUser( + 'testuser', $this->tokenService, $this->providerConfig + ); + + self::assertNull($result); + } + + public function testFindProviderForUserReturnsNullWhenNoTokensExist(): void + { + $this->providerConfig->method('listEnabled')->willReturn([ + ['provider_id' => 'test-provider'], + ]); + $this->tokenService->method('hasTokens')->willReturn(false); + + $result = OidcHookHelper::findProviderForUser( + 'testuser', $this->tokenService, $this->providerConfig + ); + + self::assertNull($result); + } + + public function testFindProviderForUserReturnsFirstMatchingProvider(): void + { + $this->providerConfig->method('listEnabled')->willReturn([ + ['provider_id' => 'provider-a'], + ['provider_id' => 'provider-b'], + ]); + $this->tokenService->method('hasTokens') + ->willReturnMap([ + ['testuser', 'provider-a', false], + ['testuser', 'provider-b', true], + ]); + + $result = OidcHookHelper::findProviderForUser( + 'testuser', $this->tokenService, $this->providerConfig + ); + + self::assertNotNull($result); + self::assertSame('provider-b', $result['provider_id']); + } + + public function testFindProviderForUserReturnsFirstWhenMultipleMatch(): void + { + $this->providerConfig->method('listEnabled')->willReturn([ + ['provider_id' => 'provider-a'], + ['provider_id' => 'provider-b'], + ]); + $this->tokenService->method('hasTokens')->willReturn(true); + + $result = OidcHookHelper::findProviderForUser( + 'testuser', $this->tokenService, $this->providerConfig + ); + + self::assertSame('provider-a', $result['provider_id']); + } + + // ── xoauth2Username ─────────────────────────────────────────────────────── + + public function testXoauth2UsernameReturnsUsernameAsIsWhenUseEmailFalse(): void + { + $row = ['xoauth2_use_email' => false, 'xoauth2_domain' => 'example.org']; + + self::assertSame('testuser', OidcHookHelper::xoauth2Username('testuser', $row)); + } + + public function testXoauth2UsernameReturnsUsernameAsIsWhenDomainEmpty(): void + { + $row = ['xoauth2_use_email' => true, 'xoauth2_domain' => '']; + + self::assertSame('testuser', OidcHookHelper::xoauth2Username('testuser', $row)); + } + + public function testXoauth2UsernameAppendsDomainWhenConfigured(): void + { + $row = ['xoauth2_use_email' => true, 'xoauth2_domain' => 'example.org']; + + self::assertSame( + 'testuser@example.org', + OidcHookHelper::xoauth2Username('testuser', $row) + ); + } + + public function testXoauth2UsernameDoesNotAppendDomainIfAlreadyPresent(): void + { + $row = ['xoauth2_use_email' => true, 'xoauth2_domain' => 'example.org']; + + self::assertSame( + 'testuser@other.org', + OidcHookHelper::xoauth2Username('testuser@other.org', $row) + ); + } + + public function testXoauth2UsernameReturnsUsernameWhenRowEmpty(): void + { + self::assertSame('testuser', OidcHookHelper::xoauth2Username('testuser', [])); + } + + // ── getValidAccessToken — cases that do not trigger a refresh ───────────── + + public function testGetValidAccessTokenReturnsNullWhenGetTokenSetThrows(): void + { + $this->tokenService->method('getTokenSet') + ->willThrowException(new \RuntimeException('not found')); + + $row = ['provider_id' => 'test-provider', 'token_endpoint' => 'https://idp.example.org/token']; + $injector = $this->createMock(\Horde_Injector::class); + + $result = OidcHookHelper::getValidAccessToken( + 'testuser', $row, $this->tokenService, $injector + ); + + self::assertNull($result); + } + + public function testGetValidAccessTokenReturnsTokenWhenNotExpired(): void + { + $tokenSet = new TokenSet( + accessToken: 'valid-access-token', + tokenType: 'Bearer', + expiresIn: 3600, + ); + + $this->tokenService->method('getTokenSet')->willReturn($tokenSet); + + $row = ['provider_id' => 'test-provider']; + $injector = $this->createMock(\Horde_Injector::class); + + $result = OidcHookHelper::getValidAccessToken( + 'testuser', $row, $this->tokenService, $injector + ); + + self::assertSame('valid-access-token', $result); + } + + public function testGetValidAccessTokenReturnsNullWhenExpiredAndNoRefreshToken(): void + { + $tokenSet = new TokenSet( + accessToken: 'expired-access-token', + tokenType: 'Bearer', + expiresIn: 0, + receivedAt: time() - 3600, + ); + + $this->tokenService->method('getTokenSet')->willReturn($tokenSet); + + $row = ['provider_id' => 'test-provider']; + $injector = $this->createMock(\Horde_Injector::class); + + $result = OidcHookHelper::getValidAccessToken( + 'testuser', $row, $this->tokenService, $injector + ); + + self::assertNull($result); + } + + public function testGetValidAccessTokenReturnsNullWhenExpiredAndNoTokenEndpoint(): void + { + $tokenSet = new TokenSet( + accessToken: 'expired-access-token', + tokenType: 'Bearer', + expiresIn: 0, + refreshToken: 'refresh-token', + receivedAt: time() - 3600, + ); + + $this->tokenService->method('getTokenSet')->willReturn($tokenSet); + + // No token_endpoint in row — ProviderConfig::fromArray will return null endpoint + $row = ['provider_id' => 'test-provider']; + $injector = $this->createMock(\Horde_Injector::class); + + $result = OidcHookHelper::getValidAccessToken( + 'testuser', $row, $this->tokenService, $injector + ); + + self::assertNull($result); + } +} diff --git a/test/Unit/Service/OidcPreLogoutHandlerTest.php b/test/Unit/Service/OidcPreLogoutHandlerTest.php new file mode 100644 index 00000000..6f64e17e --- /dev/null +++ b/test/Unit/Service/OidcPreLogoutHandlerTest.php @@ -0,0 +1,246 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Test\Unit\Service; + +use Horde\Core\Service\OAuthProviderConfigRepository; +use Horde\Core\Service\OAuthTokenService; +use Horde\Core\Service\OidcPreLogoutHandler; +use Horde\Core\Service\PreLogoutHandlerInterface; +use Horde\Core\Uri\RouteUrlWriter; +use Horde_Auth; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Log\NullLogger; + +#[CoversClass(OidcPreLogoutHandler::class)] +final class OidcPreLogoutHandlerTest extends TestCase +{ + private OAuthProviderConfigRepository $providerConfig; + private OAuthTokenService $tokenService; + private RouteUrlWriter $urlWriter; + private OidcPreLogoutHandler $handler; + + protected function setUp(): void + { + $this->providerConfig = $this->createStub(OAuthProviderConfigRepository::class); + $this->tokenService = $this->createStub(OAuthTokenService::class); + $this->urlWriter = $this->createStub(RouteUrlWriter::class); + + $this->urlWriter->method('absoluteUrlFor') + ->willReturn('https://horde.example.org/horde/services/portal/'); + + $this->handler = new OidcPreLogoutHandler( + providerConfig: $this->providerConfig, + tokenService: $this->tokenService, + urlWriter: $this->urlWriter, + httpClient: $this->createStub(ClientInterface::class), + requestFactory: $this->createStub(RequestFactoryInterface::class), + streamFactory: $this->createStub(StreamFactoryInterface::class), + logger: new NullLogger(), + ); + } + + private function makeHandler( + OAuthProviderConfigRepository $providerConfig, + OAuthTokenService $tokenService, + ): OidcPreLogoutHandler { + return new OidcPreLogoutHandler( + providerConfig: $providerConfig, + tokenService: $tokenService, + urlWriter: $this->urlWriter, + httpClient: $this->createStub(ClientInterface::class), + requestFactory: $this->createStub(RequestFactoryInterface::class), + streamFactory: $this->createStub(StreamFactoryInterface::class), + logger: new NullLogger(), + ); + } + + public function testImplementsInterface(): void + { + self::assertInstanceOf(PreLogoutHandlerInterface::class, $this->handler); + } + + public function testReturnsEmptyArrayOnSessionExpiry(): void + { + // REASON_SESSION must be ignored — only voluntary logouts are handled + $providerConfig = $this->createMock(OAuthProviderConfigRepository::class); + $providerConfig->expects(self::never())->method('listEnabled'); + $handler = $this->makeHandler($providerConfig, $this->tokenService); + + $result = $handler->onBeforeLogout('testuser', Horde_Auth::REASON_SESSION); + + self::assertSame([], $result); + } + + public function testReturnsEmptyArrayWhenNoTokensExist(): void + { + $this->providerConfig->method('listEnabled')->willReturn([ + ['provider_id' => 'test-provider', 'logout_type' => 'local'], + ]); + $this->tokenService->method('hasTokens')->willReturn(false); + + $result = $this->handler->onBeforeLogout('testuser', Horde_Auth::REASON_LOGOUT); + + self::assertSame([], $result); + } + + public function testLocalStrategyRemovesTokensAndReturnsEmpty(): void + { + $this->providerConfig->method('listEnabled')->willReturn([ + ['provider_id' => 'test-provider', 'logout_type' => 'local'], + ]); + $this->tokenService->method('hasTokens')->willReturn(true); + + $tokenService = $this->createMock(OAuthTokenService::class); + $tokenService->method('hasTokens')->willReturn(true); + $tokenService->expects(self::once()) + ->method('remove') + ->with('testuser', 'test-provider'); + + $handler = $this->makeHandler($this->providerConfig, $tokenService); + $result = $handler->onBeforeLogout('testuser', Horde_Auth::REASON_LOGOUT); + + self::assertSame([], $result); + } + + public function testDefaultStrategyIsLocal(): void + { + // logout_type absent — defaults to 'local' + $this->providerConfig->method('listEnabled')->willReturn([ + ['provider_id' => 'test-provider'], + ]); + + $tokenService = $this->createMock(OAuthTokenService::class); + $tokenService->method('hasTokens')->willReturn(true); + $tokenService->expects(self::once())->method('remove'); + + $handler = $this->makeHandler($this->providerConfig, $tokenService); + $result = $handler->onBeforeLogout('testuser', Horde_Auth::REASON_LOGOUT); + + self::assertSame([], $result); + } + + public function testSloStrategyReturnsRedirectWithConfiguredEndpoint(): void + { + $this->providerConfig->method('listEnabled')->willReturn([[ + 'provider_id' => 'test-provider', + 'logout_type' => 'slo', + 'end_session_endpoint' => 'https://idp.example.org/oidc/logout', + 'post_logout_redirect_uri' => 'https://horde.example.org/horde/login.php', + ]]); + $this->tokenService->method('hasTokens')->willReturn(true); + + $result = $this->handler->onBeforeLogout('testuser', Horde_Auth::REASON_LOGOUT); + + self::assertArrayHasKey('redirect', $result); + self::assertStringStartsWith('https://idp.example.org/oidc/logout', $result['redirect']); + self::assertStringContainsString('post_logout_redirect_uri=', $result['redirect']); + self::assertStringContainsString( + urlencode('https://horde.example.org/horde/login.php'), + $result['redirect'] + ); + } + + public function testSloStrategyFallsBackToPortalWhenNoPostLogoutUri(): void + { + $this->providerConfig->method('listEnabled')->willReturn([[ + 'provider_id' => 'test-provider', + 'logout_type' => 'slo', + 'end_session_endpoint' => 'https://idp.example.org/oidc/logout', + ]]); + $this->tokenService->method('hasTokens')->willReturn(true); + + $result = $this->handler->onBeforeLogout('testuser', Horde_Auth::REASON_LOGOUT); + + self::assertArrayHasKey('redirect', $result); + self::assertStringContainsString( + urlencode('https://horde.example.org/horde/services/portal/'), + $result['redirect'] + ); + } + + public function testSloStrategyReturnsEmptyWhenNoEndpointAvailable(): void + { + // No end_session_endpoint and no discoverable issuer + $this->providerConfig->method('listEnabled')->willReturn([[ + 'provider_id' => 'test-provider', + 'logout_type' => 'slo', + 'issuer' => '', + ]]); + $this->tokenService->method('hasTokens')->willReturn(true); + + $result = $this->handler->onBeforeLogout('testuser', Horde_Auth::REASON_LOGOUT); + + self::assertSame([], $result); + } + + public function testTokenRemovalFailureDoesNotPreventLogout(): void + { + $this->providerConfig->method('listEnabled')->willReturn([ + ['provider_id' => 'test-provider', 'logout_type' => 'local'], + ]); + $this->tokenService->method('hasTokens')->willReturn(true); + $this->tokenService->method('remove') + ->willThrowException(new \RuntimeException('DB error')); + + // Must not throw — failing handler must never prevent logout + $result = $this->handler->onBeforeLogout('testuser', Horde_Auth::REASON_LOGOUT); + + self::assertIsArray($result); + } + + public function testUsesFirstProviderWithTokens(): void + { + $this->providerConfig->method('listEnabled')->willReturn([ + ['provider_id' => 'provider-a', 'logout_type' => 'local'], + ['provider_id' => 'provider-b', 'logout_type' => 'local'], + ]); + + $tokenService = $this->createMock(OAuthTokenService::class); + $tokenService->method('hasTokens') + ->willReturnMap([ + ['testuser', 'provider-a', false], + ['testuser', 'provider-b', true], + ]); + $tokenService->expects(self::once()) + ->method('remove') + ->with('testuser', 'provider-b'); + + $handler = $this->makeHandler($this->providerConfig, $tokenService); + $handler->onBeforeLogout('testuser', Horde_Auth::REASON_LOGOUT); + } + + public function testSepIsAmpersandWhenEndpointAlreadyHasQueryString(): void + { + $this->providerConfig->method('listEnabled')->willReturn([[ + 'provider_id' => 'test-provider', + 'logout_type' => 'slo', + 'end_session_endpoint' => 'https://idp.example.org/oidc/logout?foo=bar', + ]]); + $this->tokenService->method('hasTokens')->willReturn(true); + + $result = $this->handler->onBeforeLogout('testuser', Horde_Auth::REASON_LOGOUT); + + self::assertStringContainsString( + 'https://idp.example.org/oidc/logout?foo=bar&post_logout_redirect_uri=', + $result['redirect'] + ); + } +}