diff --git a/src/Storageless/Http/SessionMiddleware.php b/src/Storageless/Http/SessionMiddleware.php index abd8b3d0..7d6a0a35 100644 --- a/src/Storageless/Http/SessionMiddleware.php +++ b/src/Storageless/Http/SessionMiddleware.php @@ -21,27 +21,14 @@ namespace PSR7Sessions\Storageless\Http; use BadMethodCallException; -use DateInterval; -use Dflydev\FigCookies\FigResponseCookies; -use Dflydev\FigCookies\SetCookie; use InvalidArgumentException; -use Lcobucci\JWT\Encoding\ChainedFormatter; -use Lcobucci\JWT\Token; -use Lcobucci\JWT\UnencryptedToken; -use Lcobucci\JWT\Validation\Constraint\SignedWith; -use Lcobucci\JWT\Validation\Constraint\StrictValidAt; -use OutOfBoundsException; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use PSR7Sessions\Storageless\Http\ClientFingerprint\SameOriginRequest; -use PSR7Sessions\Storageless\Session\DefaultSessionData; +use PSR7Sessions\Storageless\Service\StoragelessSession; use PSR7Sessions\Storageless\Session\LazySession; use PSR7Sessions\Storageless\Session\SessionInterface; -use stdClass; - -use function sprintf; /** @immutable */ final class SessionMiddleware implements MiddlewareInterface @@ -49,9 +36,12 @@ final class SessionMiddleware implements MiddlewareInterface public const SESSION_CLAIM = 'session-data'; public const SESSION_ATTRIBUTE = 'session'; + private readonly StoragelessSession $service; + public function __construct( private readonly Configuration $config, ) { + $this->service = new StoragelessSession($config); } /** @@ -62,149 +52,11 @@ public function __construct( */ public function process(Request $request, RequestHandlerInterface $handler): Response { - $sameOriginRequest = new SameOriginRequest($this->config->getClientFingerprintConfiguration(), $request); - $token = $this->parseToken($request, $sameOriginRequest); - $sessionContainer = LazySession::fromContainerBuildingCallback(function () use ($token): SessionInterface { - return $this->extractSessionContainer($token); - }); - - return $this->appendToken( - $sessionContainer, - $handler->handle($request->withAttribute($this->config->getSessionAttribute(), $sessionContainer)), - $token, - $sameOriginRequest, + return $this->service->appendSession( + LazySession::fromToken($this->service->requestToToken($request)), + $request, + null, + $handler ); } - - /** - * Extract the token from the given request object - */ - private function parseToken(Request $request, SameOriginRequest $sameOriginRequest): UnencryptedToken|null - { - /** @var array $cookies */ - $cookies = $request->getCookieParams(); - $cookieName = $this->config->getCookie()->getName(); - - if (! isset($cookies[$cookieName])) { - return null; - } - - $cookie = $cookies[$cookieName]; - if ($cookie === '') { - return null; - } - - $jwtConfiguration = $this->config->getJwtConfiguration(); - try { - $token = $jwtConfiguration->parser()->parse($cookie); - } catch (InvalidArgumentException) { - return null; - } - - if (! $token instanceof UnencryptedToken) { - return null; - } - - $constraints = [ - new StrictValidAt($this->config->getClock()), - new SignedWith($jwtConfiguration->signer(), $jwtConfiguration->verificationKey()), - $sameOriginRequest, - ]; - - if (! $jwtConfiguration->validator()->validate($token, ...$constraints)) { - return null; - } - - return $token; - } - - /** @throws OutOfBoundsException */ - private function extractSessionContainer(UnencryptedToken|null $token): SessionInterface - { - if (! $token) { - return DefaultSessionData::newEmptySession(); - } - - try { - return DefaultSessionData::fromDecodedTokenData( - (object) $token->claims()->get(self::SESSION_CLAIM, new stdClass()), - ); - } catch (BadMethodCallException) { - return DefaultSessionData::newEmptySession(); - } - } - - /** - * @throws BadMethodCallException - * @throws InvalidArgumentException - */ - private function appendToken( - SessionInterface $sessionContainer, - Response $response, - Token|null $token, - SameOriginRequest $sameOriginRequest, - ): Response { - $sessionContainerChanged = $sessionContainer->hasChanged(); - - if ($sessionContainerChanged && $sessionContainer->isEmpty()) { - return FigResponseCookies::set($response, $this->getExpirationCookie()); - } - - if ($sessionContainerChanged || $this->shouldTokenBeRefreshed($token)) { - return FigResponseCookies::set($response, $this->getTokenCookie($sessionContainer, $sameOriginRequest)); - } - - return $response; - } - - private function shouldTokenBeRefreshed(Token|null $token): bool - { - if ($token === null) { - return false; - } - - return $token->hasBeenIssuedBefore( - $this->config->getClock() - ->now() - ->sub(new DateInterval(sprintf('PT%sS', $this->config->getRefreshTime()))), - ); - } - - /** @throws BadMethodCallException */ - private function getTokenCookie(SessionInterface $sessionContainer, SameOriginRequest $sameOriginRequest): SetCookie - { - $now = $this->config->getClock()->now(); - $expiresAt = $now->add(new DateInterval(sprintf('PT%sS', $this->config->getIdleTimeout()))); - - $jwtConfiguration = $this->config->getJwtConfiguration(); - - $builder = $jwtConfiguration->builder(ChainedFormatter::withUnixTimestampDates()) - ->issuedAt($now) - ->canOnlyBeUsedAfter($now) - ->expiresAt($expiresAt) - ->withClaim(self::SESSION_CLAIM, $sessionContainer); - - $builder = $sameOriginRequest->configure($builder); - - return $this - ->config->getCookie() - ->withValue( - $builder - ->getToken($jwtConfiguration->signer(), $jwtConfiguration->signingKey()) - ->toString(), - ) - ->withExpires($expiresAt); - } - - private function getExpirationCookie(): SetCookie - { - return $this - ->config->getCookie() - ->withValue(null) - ->withExpires( - $this->config->getClock() - ->now() - ->modify('-30 days'), - ); - } } diff --git a/src/Storageless/Service/SessionStorage.php b/src/Storageless/Service/SessionStorage.php index 9b1c521d..79e5aaee 100644 --- a/src/Storageless/Service/SessionStorage.php +++ b/src/Storageless/Service/SessionStorage.php @@ -20,15 +20,14 @@ namespace PSR7Sessions\Storageless\Service; -use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use PSR7Sessions\Storageless\Session\SessionInterface; +use Psr\Http\Server\RequestHandlerInterface; interface SessionStorage { - /** @psalm-return ($message is ResponseInterface ? ResponseInterface : RequestInterface) */ - public function withSession(ServerRequestInterface|ResponseInterface $message, SessionInterface $session): RequestInterface|ResponseInterface; + public function appendSession(SessionInterface $session, ServerRequestInterface $request, ?ResponseInterface $response, ?RequestHandlerInterface $handler = null): ResponseInterface; - public function get(ServerRequestInterface|ResponseInterface $message): SessionInterface; + public function getSession(ServerRequestInterface $request): SessionInterface; } diff --git a/src/Storageless/Service/StoragelessSession.php b/src/Storageless/Service/StoragelessSession.php index a3622a13..995d9791 100644 --- a/src/Storageless/Service/StoragelessSession.php +++ b/src/Storageless/Service/StoragelessSession.php @@ -20,247 +20,188 @@ namespace PSR7Sessions\Storageless\Service; +use BadMethodCallException; use DateInterval; -use DateTimeImmutable; -use Dflydev\FigCookies\Cookie; -use Dflydev\FigCookies\FigRequestCookies; use Dflydev\FigCookies\FigResponseCookies; -use Dflydev\FigCookies\Modifier\SameSite; use Dflydev\FigCookies\SetCookie; -use Dflydev\FigCookies\SetCookies; -use Exception; -use Lcobucci\Clock\SystemClock; -use Lcobucci\JWT\Configuration; +use InvalidArgumentException; use Lcobucci\JWT\Encoding\ChainedFormatter; -use Lcobucci\JWT\Signer; +use Lcobucci\JWT\Token; use Lcobucci\JWT\UnencryptedToken; use Lcobucci\JWT\Validation\Constraint\SignedWith; use Lcobucci\JWT\Validation\Constraint\StrictValidAt; -use Psr\Clock\ClockInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; +use PSR7Sessions\Storageless\Http\ClientFingerprint\SameOriginRequest; +use PSR7Sessions\Storageless\Http\Configuration; +use PSR7Sessions\Storageless\Http\SessionMiddleware; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; use PSR7Sessions\Storageless\Session\DefaultSessionData; +use PSR7Sessions\Storageless\Session\LazySession; use PSR7Sessions\Storageless\Session\SessionInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; use stdClass; -use Throwable; - -use function array_key_exists; -use function assert; -use function is_string; -use function sprintf; final class StoragelessSession implements SessionStorage { - private const DEFAULT_COOKIE = '__Secure-slsession'; - private const SESSION_CLAIM = 'session-data'; - private const DEFAULT_IDLE_TIMEOUT = 300; - - private readonly Cookie|SetCookie $cookie; + public const SESSION_CLAIM = 'session-data'; public function __construct( - private readonly Configuration $configuration, - private readonly int $idleTimeout, - Cookie|SetCookie $cookie, - private readonly ClockInterface $clock, + private readonly Configuration $config, ) { - $this->cookie = clone $cookie; } - public static function fromSymmetricKeyDefaults( - Signer\Key $symmetricKey, - int $idleTimeout = self::DEFAULT_IDLE_TIMEOUT, - Cookie|SetCookie|null $cookie = null, - ClockInterface|null $clock = null, - ): self { - return new self( - Configuration::forSymmetricSigner( - new Signer\Hmac\Sha256(), - $symmetricKey, - ), - $idleTimeout, - $cookie ?? SetCookie::create(self::DEFAULT_COOKIE) - ->withSecure(true) - ->withHttpOnly(true) - ->withSameSite(SameSite::lax()) - ->withPath('/'), - $clock ?? SystemClock::fromUTC(), - ); - } + public function appendSession(SessionInterface $session, ServerRequestInterface $request, ?Response $response = null, ?RequestHandlerInterface $handler = null): Response { + $sameOriginRequest = new SameOriginRequest($this->config->getClientFingerprintConfiguration(), $request); + $token = $this->requestToToken($request, $sameOriginRequest); + $sessionContainer = LazySession::fromToken($token); - public static function fromRsaAsymmetricKeyDefaults( - Signer\Key $privateRsaKey, - Signer\Key $publicRsaKey, - int $idleTimeout = self::DEFAULT_IDLE_TIMEOUT, - Cookie|SetCookie|null $cookie = null, - ClockInterface|null $clock = null, - ): self { - return new self( - Configuration::forAsymmetricSigner( - new Signer\Rsa\Sha256(), - $privateRsaKey, - $publicRsaKey, - ), - $idleTimeout, - $cookie ?? SetCookie::create(self::DEFAULT_COOKIE) - ->withSecure(true) - ->withHttpOnly(true) - ->withSameSite(SameSite::lax()) - ->withPath('/'), - $clock ?? SystemClock::fromUTC(), - ); - } + $middleware = new SessionMiddleware($this->config); - public function withSession(ServerRequestInterface|ResponseInterface $message, SessionInterface $session): RequestInterface|ResponseInterface - { - if ($message instanceof ResponseInterface) { - return $this->withResponseSession($message, $session, $this->clock->now()); - } + /** @var RequestHandlerInterface $handler */ + $handler ??= fn (ResponseInterface $response): RequestHandlerInterface => new class($response) implements RequestHandlerInterface { + public function __construct( + private readonly ResponseInterface $response + ) {} - return $this->withRequestSession($message, $session, $this->clock->now()); - } + public function handle(Request $request): ResponseInterface { + return $this->response; + } + }; - public function get(ServerRequestInterface|ResponseInterface $message): SessionInterface - { - $cookie = $this->getCookieFromMessage($message); + $response = $response ?? $handler->handle($request->withAttribute($this->config->getSessionAttribute(), $sessionContainer)); + + return $this->appendToken( + $sessionContainer, + $response, + $token, + $sameOriginRequest, + ); - return $cookie === null - ? DefaultSessionData::newEmptySession() - : $this->cookieToSession($cookie); + return $middleware->process($request, $handler($response)); } - public function getCookieFromMessage(ServerRequestInterface|ResponseInterface $message): SetCookie|Cookie|null + public function getSession(Request $request): SessionInterface { - // TODO: Why we cannot use Cookies::fromRequest() ? - // See: https://github.com/dflydev/dflydev-fig-cookies/issues/57 - if ($message instanceof ServerRequestInterface) { - $cookies = $message->getCookieParams(); + $sameOriginRequest = new SameOriginRequest($this->config->getClientFingerprintConfiguration(), $request); + $token = $this->requestToToken($request, $sameOriginRequest); - if (! array_key_exists($this->cookie->getName(), $cookies)) { - return null; - } + return LazySession::fromToken($token); + } - $cookieValue = $cookies[$this->cookie->getName()]; - assert(is_string($cookieValue)); + /** + * @throws BadMethodCallException + * @throws InvalidArgumentException + */ + private function appendToken( + SessionInterface $sessionContainer, + Response $response, + Token|null $token, + SameOriginRequest $sameOriginRequest, + ): Response { + $sessionContainerChanged = $sessionContainer->hasChanged(); + + if ($sessionContainerChanged && $sessionContainer->isEmpty()) { + return FigResponseCookies::set($response, $this->getExpirationCookie()); + } - return Cookie::create($this->cookie->getName(), $cookieValue === '' ? null : $cookieValue); + if ($sessionContainerChanged || $this->shouldTokenBeRefreshed($token)) { + return FigResponseCookies::set($response, $this->getTokenCookie($sessionContainer, $sameOriginRequest)); } - return SetCookies::fromResponse($message)->get($this->cookie->getName()); + return $response; } - public function cookieToToken(SetCookie|Cookie|null $cookie): UnencryptedToken|null + /** @throws BadMethodCallException */ + private function getTokenCookie(SessionInterface $sessionContainer, SameOriginRequest $sameOriginRequest): SetCookie { - if ($cookie === null) { - return null; - } + $now = $this->config->getClock()->now(); + $expiresAt = $now->add(new DateInterval(sprintf('PT%sS', $this->config->getIdleTimeout()))); + + $jwtConfiguration = $this->config->getJwtConfiguration(); + + $builder = $jwtConfiguration->builder(ChainedFormatter::withUnixTimestampDates()) + ->issuedAt($now) + ->canOnlyBeUsedAfter($now) + ->expiresAt($expiresAt) + ->withClaim(self::SESSION_CLAIM, $sessionContainer); + + $builder = $sameOriginRequest->configure($builder); + + return $this + ->config->getCookie() + ->withValue( + $builder + ->getToken($jwtConfiguration->signer(), $jwtConfiguration->signingKey()) + ->toString(), + ) + ->withExpires($expiresAt); + } - $jwt = $cookie->getValue(); + public function requestToToken(Request $request, ?SameOriginRequest $sameOriginRequest = null): ?UnencryptedToken + { + $sameOriginRequest ??= new SameOriginRequest($this->config->getClientFingerprintConfiguration(), $request); + + /** @var array $cookies */ + $cookies = $request->getCookieParams(); + $cookieName = $this->config->getCookie()->getName(); - if ($jwt === null) { + if (!isset($cookies[$cookieName])) { return null; } - if ($jwt === '') { + $cookie = $cookies[$cookieName]; + if ($cookie === '') { return null; } + $jwtConfiguration = $this->config->getJwtConfiguration(); try { - $token = $this->configuration->parser()->parse($jwt); - } catch (Throwable) { + $token = $jwtConfiguration->parser()->parse($cookie); + } catch (InvalidArgumentException) { return null; } - if (! $token instanceof UnencryptedToken) { + if (!$token instanceof UnencryptedToken) { return null; } - $isValid = $this - ->configuration - ->validator() - ->validate( - $token, - new StrictValidAt($this->clock), - new SignedWith($this->configuration->signer(), $this->configuration->verificationKey()), - ); + $constraints = [ + new StrictValidAt($this->config->getClock()), + new SignedWith($jwtConfiguration->signer(), $jwtConfiguration->verificationKey()), + $sameOriginRequest, + ]; - if ($isValid === false) { + if (!$jwtConfiguration->validator()->validate($token, ...$constraints)) { return null; } return $token; } - private function withRequestSession(RequestInterface $request, SessionInterface $session, DateTimeImmutable $now): RequestInterface + private function getExpirationCookie(): SetCookie { - if ($session->hasChanged() === false) { - return $request; - } - - if (! $this->cookie instanceof Cookie) { - throw new Exception( - 'The default cookie is not a Cookie type.', + return $this + ->config->getCookie() + ->withValue(null) + ->withExpires( + $this->config->getClock() + ->now() + ->modify('-30 days'), ); - } - - return FigRequestCookies::set( - $request, - $this->appendCookieSession($this->cookie, $session, $now), - ); } - private function withResponseSession(ResponseInterface $response, SessionInterface $session, DateTimeImmutable $now): ResponseInterface + private function shouldTokenBeRefreshed(Token|null $token): bool { - if (! $this->cookie instanceof SetCookie) { - throw new Exception( - 'The default cookie is not a SetCookie type.', - ); - } - - if ($session->isEmpty()) { - return FigResponseCookies::set( - $response, - $this->cookie->withExpires($now->modify('-30 days')), - ); - } - - return FigResponseCookies::set( - $response, - $this - ->appendCookieSession( - $this->cookie->withExpires($now->add(new DateInterval(sprintf('PT%sS', $this->idleTimeout)))), - $session, - $now, - ), - ); - } - - /** @psalm-return ($cookie is SetCookie ? SetCookie : Cookie) */ - private function appendCookieSession(SetCookie|Cookie $cookie, SessionInterface $session, DateTimeImmutable $now): SetCookie|Cookie - { - $value = $session->isEmpty() - ? null - : $this->configuration->builder(ChainedFormatter::withUnixTimestampDates()) - ->issuedAt($now) - ->canOnlyBeUsedAfter($now) - ->expiresAt($now->add(new DateInterval(sprintf('PT%sS', $this->idleTimeout)))) - ->withClaim(self::SESSION_CLAIM, $session) - ->getToken($this->configuration->signer(), $this->configuration->signingKey()) - ->toString(); - - return $cookie->withValue($value); - } - - private function cookieToSession(SetCookie|Cookie $cookie): SessionInterface - { - $token = $this->cookieToToken($cookie); - if ($token === null) { - return DefaultSessionData::newEmptySession(); + return false; } - return DefaultSessionData::fromDecodedTokenData( - (object) $token->claims()->get(self::SESSION_CLAIM, new stdClass()), + return $token->hasBeenIssuedBefore( + $this->config->getClock() + ->now() + ->sub(new DateInterval(sprintf('PT%sS', $this->config->getRefreshTime()))), ); } } diff --git a/src/Storageless/Session/LazySession.php b/src/Storageless/Session/LazySession.php index 0fb99fd5..aed2c9cc 100644 --- a/src/Storageless/Session/LazySession.php +++ b/src/Storageless/Session/LazySession.php @@ -20,6 +20,11 @@ namespace PSR7Sessions\Storageless\Session; +use BadMethodCallException; +use Lcobucci\JWT\UnencryptedToken; +use PSR7Sessions\Storageless\Http\SessionMiddleware; +use stdClass; + final class LazySession implements SessionInterface { /** @internal do not access directly: use {@see LazySession::getRealSession} instead */ @@ -47,6 +52,25 @@ public static function fromContainerBuildingCallback(callable $sessionLoader): s return new self($sessionLoader); } + public static function fromToken(?UnencryptedToken $token): self + { + return self::fromContainerBuildingCallback( + function() use ($token): SessionInterface { + if (! $token) { + return DefaultSessionData::newEmptySession(); + } + + try { + return DefaultSessionData::fromDecodedTokenData( + (object) $token->claims()->get(SessionMiddleware::SESSION_CLAIM, new stdClass()), + ); + } catch (BadMethodCallException) { + return DefaultSessionData::newEmptySession(); + } + }, + ); + } + /** * {@inheritDoc} */ diff --git a/test/StoragelessTest/Service/StoragelessSessionTest.php b/test/StoragelessTest/Service/StoragelessSessionTest.php deleted file mode 100644 index 561784d8..00000000 --- a/test/StoragelessTest/Service/StoragelessSessionTest.php +++ /dev/null @@ -1,309 +0,0 @@ - */ - public function itThrowsExceptionWhenCookieTypeIsInvalidProvider(): Generator - { - yield [ - static fn (): SessionStorage => StoragelessSession::fromSymmetricKeyDefaults( - self::makeRandomSymmetricKey(), - 0, - SetCookie::create(SessionMiddleware::DEFAULT_COOKIE), - new FrozenClock(new DateTimeImmutable('2010-05-15 16:00:00')), - ), - new ServerRequest(), - 'The default cookie is not a Cookie type.', - ]; - - yield [ - static fn (): SessionStorage => StoragelessSession::fromRsaAsymmetricKeyDefaults( - Signer\Key\InMemory::file(__DIR__ . '/../../keys/private_key.pem'), - Signer\Key\InMemory::file(__DIR__ . '/../../keys/public_key.pem'), - 0, - Cookie::create(SessionMiddleware::DEFAULT_COOKIE), - new FrozenClock(new DateTimeImmutable('2010-05-15 16:00:00')), - ), - new Response(), - 'The default cookie is not a SetCookie type.', - ]; - } - - /** - * @param Closure(): SessionStorage $sessionStorageClosure - * - * @dataProvider itThrowsExceptionWhenCookieTypeIsInvalidProvider - */ - public function testItThrowsExceptionWhenCookieTypeIsInvalid( - Closure $sessionStorageClosure, - ServerRequestInterface|ResponseInterface $message, - string $exceptionMessage, - ): void { - $sessionStorage = $sessionStorageClosure(); - - $session = $sessionStorage->get($message); - $session->set('foo', 'bar'); - - $this->expectException(Throwable::class); - $this->expectExceptionMessage($exceptionMessage); - - $sessionStorage->withSession($message, $session); - } - - /** @return Generator */ - public function itCanCustomizeACookieProvider(): Generator - { - yield [ - static fn (): SessionStorage => StoragelessSession::fromSymmetricKeyDefaults( - self::makeRandomSymmetricKey(), - ), - SessionMiddleware::buildDefaultCookie(), - ]; - - yield [ - static fn (): SessionStorage => StoragelessSession::fromRsaAsymmetricKeyDefaults( - Signer\Key\InMemory::file(__DIR__ . '/../../keys/private_key.pem'), - Signer\Key\InMemory::file(__DIR__ . '/../../keys/public_key.pem'), - ), - SessionMiddleware::buildDefaultCookie(), - ]; - - yield [ - static fn (): SessionStorage => StoragelessSession::fromSymmetricKeyDefaults( - self::makeRandomSymmetricKey(), - 100, - SessionMiddleware::buildDefaultCookie()->withPath('/foo'), - ), - SessionMiddleware::buildDefaultCookie()->withPath('/foo'), - ]; - - yield [ - static fn (): SessionStorage => StoragelessSession::fromRsaAsymmetricKeyDefaults( - Signer\Key\InMemory::file(__DIR__ . '/../../keys/private_key.pem'), - Signer\Key\InMemory::file(__DIR__ . '/../../keys/public_key.pem'), - 100, - SessionMiddleware::buildDefaultCookie()->withPath('/foo'), - ), - SessionMiddleware::buildDefaultCookie()->withPath('/foo'), - ]; - } - - /** @return Generator */ - public function itCanCustomizeAClockProvider(): Generator - { - yield [ - static fn (): SessionStorage => StoragelessSession::fromSymmetricKeyDefaults( - self::makeRandomSymmetricKey(), - 0, - ), - SystemClock::fromUTC(), - ]; - - yield [ - static fn (): SessionStorage => StoragelessSession::fromRsaAsymmetricKeyDefaults( - Signer\Key\InMemory::file(__DIR__ . '/../../keys/private_key.pem'), - Signer\Key\InMemory::file(__DIR__ . '/../../keys/public_key.pem'), - 0, - ), - SystemClock::fromUTC(), - ]; - - yield [ - static fn (): SessionStorage => StoragelessSession::fromSymmetricKeyDefaults( - self::makeRandomSymmetricKey(), - 0, - null, - new FrozenClock(new DateTimeImmutable('2010-05-15 16:00:00')), - ), - new FrozenClock(new DateTimeImmutable('2010-05-15 16:00:00')), - ]; - - yield [ - static fn (): SessionStorage => StoragelessSession::fromRsaAsymmetricKeyDefaults( - Signer\Key\InMemory::file(__DIR__ . '/../../keys/private_key.pem'), - Signer\Key\InMemory::file(__DIR__ . '/../../keys/public_key.pem'), - 0, - null, - new FrozenClock(new DateTimeImmutable('2010-05-15 16:00:00')), - ), - new FrozenClock(new DateTimeImmutable('2010-05-15 16:00:00')), - ]; - } - - /** - * @param Closure(): SessionStorage $sessionStorageClosure - * - * @dataProvider itCanCustomizeACookieProvider - */ - public function testItCanCustomizeACookie(Closure $sessionStorageClosure, SetCookie|null $cookie): void - { - $sessionStorage = $sessionStorageClosure(); - - $response = new Response(); - - $session = $sessionStorage->get($response); - $session->set('foo', 'bar'); - - $response = $sessionStorage->withSession($response, $session); - - $cookie ??= SessionMiddleware::buildDefaultCookie(); - - $this->assertEquals( - $cookie->getPath(), - $this->getCookie($response)->getPath(), - ); - - $this->assertEquals( - $cookie->getHttpOnly(), - $this->getCookie($response)->getHttpOnly(), - ); - - $this->assertEquals( - $cookie->getSecure(), - $this->getCookie($response)->getSecure(), - ); - } - - /** @return Generator */ - public function itCanCreateACookieWhenItIsNotSetProvider(): Generator - { - yield [ - static fn (): SessionStorage => StoragelessSession::fromSymmetricKeyDefaults( - self::makeRandomSymmetricKey(), - ), - ]; - - yield [ - static fn (): SessionStorage => StoragelessSession::fromRsaAsymmetricKeyDefaults( - Signer\Key\InMemory::file(__DIR__ . '/../../keys/private_key.pem'), - Signer\Key\InMemory::file(__DIR__ . '/../../keys/public_key.pem'), - ), - ]; - } - - /** - * @param Closure(): SessionStorage $sessionStorageClosure - * - * @dataProvider itCanCreateACookieWhenItIsNotSetProvider - */ - public function testItCanCreateACookieWhenItIsNotSet(Closure $sessionStorageClosure): void - { - $sessionStorage = $sessionStorageClosure(); - - $response = new Response(); - - $session = $sessionStorage->get($response); - $session->set('foo', 'bar'); - - $response = $sessionStorage->withSession($response, $session); - - $this->assertTrue( - $this->getCookie($response)->getSecure(), - ); - - $this->assertTrue( - $this->getCookie($response)->getHttpOnly(), - ); - - $this->assertEquals( - SameSite::lax(), - $this->getCookie($response)->getSameSite(), - ); - - $this->assertEquals( - '/', - $this->getCookie($response)->getPath(), - ); - } - - /** - * @param Closure(): SessionStorage $sessionStorageClosure - * - * @dataProvider itCanCustomizeAClockProvider - */ - public function testItCanUseACustomClock(Closure $sessionStorageClosure, ClockInterface $clock): void - { - $sessionStorage = $sessionStorageClosure(); - - $response = new Response(); - $session = $sessionStorage->get($response); - $session->set('foo', 'bar'); - - $cookie = $this->getCookie($sessionStorage->withSession($response, $session)); - - $this->assertEquals($clock->now()->getTimestamp(), $cookie->getExpires()); - } - - /** @return Generator */ - public function itCanDetectNullOrEmptyJWTProvider(): Generator - { - yield [SessionMiddleware::buildDefaultCookie()->withValue('')]; - - yield [SessionMiddleware::buildDefaultCookie()->withValue(null)]; - } - - /** @dataProvider itCanDetectNullOrEmptyJWTProvider */ - public function testItCanDetectNullOrEmptyJWT(SetCookie $cookie): void - { - $sessionStorage = StoragelessSession::fromSymmetricKeyDefaults( - self::makeRandomSymmetricKey(), - ); - - $this->assertNull($sessionStorage->cookieToToken($cookie)); - } - - private function getCookie(ResponseInterface $response, string $name = SessionMiddleware::DEFAULT_COOKIE): SetCookie - { - return FigResponseCookies::get($response, $name); - } - - private static function makeRandomSymmetricKey(): Signer\Key\InMemory - { - return Signer\Key\InMemory::plainText('test-key_' . base64_encode(random_bytes(128))); - } -}