diff --git a/lib/Horde/Core/Secret/Cbc.php b/lib/Horde/Core/Secret/Cbc.php index 3fa06a88..1a58ac49 100644 --- a/lib/Horde/Core/Secret/Cbc.php +++ b/lib/Horde/Core/Secret/Cbc.php @@ -12,6 +12,7 @@ * @package Core */ +use Horde\Core\Secret\SessionSecret; use Horde\Crypt\Blowfish\Blowfish; /** @@ -32,7 +33,7 @@ * @package Core * @since 2.20.0 */ -class Horde_Core_Secret_Cbc extends Horde_Core_Secret +class Horde_Core_Secret_Cbc extends Horde_Core_Secret implements SessionSecret { /** * Key used for current cached cipher object. diff --git a/src/Auth/Jwt/JwtService.php b/src/Auth/Jwt/JwtService.php index 4f4b2c64..f1240af7 100644 --- a/src/Auth/Jwt/JwtService.php +++ b/src/Auth/Jwt/JwtService.php @@ -20,8 +20,10 @@ namespace Horde\Core\Auth\Jwt; +use Horde\Injector\Attribute\Factory; use InvalidArgumentException; +#[Factory(factory: JwtServiceFactory::class, method: 'create')] class JwtService { private Hs256Generator $generator; diff --git a/src/Config/StateFactory.php b/src/Config/StateFactory.php new file mode 100644 index 00000000..6960405b --- /dev/null +++ b/src/Config/StateFactory.php @@ -0,0 +1,50 @@ +getInstance(ConfigLoader::class)->load('horde'); + } +} diff --git a/src/DefaultInjectorBindings.php b/src/DefaultInjectorBindings.php index cd990a90..99645ac7 100644 --- a/src/DefaultInjectorBindings.php +++ b/src/DefaultInjectorBindings.php @@ -22,6 +22,8 @@ use Horde\Core\Config\ConfigMetadataProvider; use Horde\Core\Config\Driver\DriverRepository; use Horde\Core\Config\RegistryConfigLoader; +use Horde\Core\Config\State; +use Horde\Core\Config\StateFactory; use Horde\Core\Editor\TinymcePageBinder; use Horde\Core\Factory\ApiRegistryFactory; use Horde\Core\Factory\ApplicationServiceFactory; @@ -265,6 +267,7 @@ public function register(Injector $injector): void JwtService::class => JwtServiceFactory::class, AuthenticationService::class => AuthenticationServiceFactory::class, ConfigLoader::class => ConfigLoaderFactory::class, + State::class => StateFactory::class, DriverRepository::class => DriverRepositoryFactory::class, ConfigMetadataProvider::class => ConfigMetadataProviderFactory::class, HordeDbService::class => DbServiceFactory::class, diff --git a/src/Factory/SessionHandlerFactory.php b/src/Factory/SessionHandlerFactory.php index c07877c0..84d106fe 100644 --- a/src/Factory/SessionHandlerFactory.php +++ b/src/Factory/SessionHandlerFactory.php @@ -20,7 +20,7 @@ use Horde\Core\Service\HordeDbService; use Horde\Core\Session\HordeSessionFactory; use Horde\HashTable\LockableHashTable; -use Horde\SessionHandler\NativePhpSessionSerializer; +use Horde\SessionHandler\DefaultSessionSerializer; use Horde\SessionHandler\SessionHandler; use Horde\SessionHandler\SessionStorageBackend; use Horde\SessionHandler\Storage\BuiltinBackend; @@ -90,7 +90,7 @@ public function create(Injector $injector): SessionHandler return new SessionHandler( backend: $backend, - serializer: new NativePhpSessionSerializer(), + serializer: new DefaultSessionSerializer(), sessionFactory: $this->createSessionFactory($injector), events: $this->getEventDispatcher($injector), ); diff --git a/src/Middleware/JwtSessionLoader.php b/src/Middleware/JwtSessionLoader.php index a15a03a1..444ca429 100644 --- a/src/Middleware/JwtSessionLoader.php +++ b/src/Middleware/JwtSessionLoader.php @@ -64,7 +64,7 @@ class JwtSessionLoader implements MiddlewareInterface public const COOKIE_NAME = 'horde_jwt_refresh'; public function __construct( - private readonly JwtService $jwtService, + private readonly ?JwtService $jwtService, private readonly SessionHandler $sessionHandler, private readonly LoggerInterface $logger, ) {} @@ -90,6 +90,13 @@ public function process( */ private function resolveSession(ServerRequestInterface $request): ?HordeSession { + if ($this->jwtService === null) { + // JWT is not configured for this install. Nothing to verify. + // Downstream middleware (e.g. HordeSessionMiddleware) will + // mint a fresh session via the cookie path. + return null; + } + $cookies = $request->getCookieParams(); $jwt = $cookies[self::COOKIE_NAME] ?? null; if (!is_string($jwt) || $jwt === '') { diff --git a/src/Secret/SessionSecret.php b/src/Secret/SessionSecret.php new file mode 100644 index 00000000..38d008b0 --- /dev/null +++ b/src/Secret/SessionSecret.php @@ -0,0 +1,63 @@ +getInstance('Horde_Secret_Cbc'); + $resolved = $injector->getInstance('Horde_Secret_Cbc'); + if ($resolved instanceof SessionSecret) { + $secret = $resolved; + } + // Resolved-but-not-SessionSecret: legacy fixture or test + // double. Treat as if no cipher were available; lifecycle + // no-ops the setKey/clearKey calls. } catch (\Throwable) { // No Horde_Secret_Cbc binding configured. Tests and // bootstrap-time contexts may run without one. Lifecycle diff --git a/test/Unit/Session/SessionLifecycleTest.php b/test/Unit/Session/SessionLifecycleTest.php index 8b036ce5..13f99d24 100644 --- a/test/Unit/Session/SessionLifecycleTest.php +++ b/test/Unit/Session/SessionLifecycleTest.php @@ -12,6 +12,7 @@ namespace Horde\Core\Test\Unit\Session; use Horde\Core\Config\State; +use Horde\Core\Secret\SessionSecret; use Horde\Core\Session\HordeSession; use Horde\Core\Session\SessionConfigFactory; use Horde\Core\Session\SessionLifecycle; @@ -55,6 +56,7 @@ class SessionLifecycleTest extends TestCase private function build( array $conf = [], ?HordeSession $session = null, + ?SessionSecret $secret = null, ): SessionLifecycle { $injector = new Injector(new TopLevel()); $session ??= new HordeSession(new SessionId('test-id'), []); @@ -63,7 +65,7 @@ private function build( $handler = new SessionHandler(new BuiltinBackend()); $config = (new SessionConfigFactory())->fromState(new State($conf)); - return new SessionLifecycle($injector, $handler, $config); + return new SessionLifecycle($injector, $handler, $config, $secret); } // --------------------------------------------------------------- @@ -83,6 +85,39 @@ public function testIsInactiveByDefault(): void self::assertFalse($this->build()->isActive()); } + #[Test] + public function testAcceptsSessionSecretImplementation(): void + { + // Regression sentinel for the binding-name-vs-class confusion. + // SessionLifecycle's secret arg is typed against the + // SessionSecret interface, not the legacy Horde_Secret_Cbc + // binding name. Anything that implements the interface + // (including production's Horde_Core_Secret_Cbc) must satisfy + // the constructor type check. + $secret = $this->createStub(SessionSecret::class); + $lifecycle = $this->build(secret: $secret); + self::assertInstanceOf(SessionLifecycle::class, $lifecycle); + } + + #[Test] + public function testAcceptsHordeCoreSecretCbcViaInterfaceContract(): void + { + // Belt-and-braces: the actual production class is + // Horde_Core_Secret_Cbc which now implements SessionSecret. + // The class is loadable in unit context (no DB / no session + // module needed for the constructor itself) so we can + // instantiate it without arguments and confirm it satisfies + // the lifecycle's type constraint. + if (!class_exists(\Horde_Core_Secret_Cbc::class)) { + self::markTestSkipped('Horde_Core_Secret_Cbc not loadable in this test environment'); + } + $secret = new \Horde_Core_Secret_Cbc(); + self::assertInstanceOf(SessionSecret::class, $secret); + + $lifecycle = $this->build(secret: $secret); + self::assertInstanceOf(SessionLifecycle::class, $lifecycle); + } + // --------------------------------------------------------------- // Cookie-domain guard // ---------------------------------------------------------------