diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b4cd2c..6af09c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 3.0.3 under development -- no changes in this release. +- Enh #86: Remove `yiisoft/cookies` dependency (@vjik) +- Bug #86: `NullSession::getCookieParameters()` now returns proper cookie parameters instead of an empty array (@vjik) ## 3.0.2 August 26, 2026 diff --git a/composer.json b/composer.json index 3ab1e57..f769358 100644 --- a/composer.json +++ b/composer.json @@ -33,8 +33,7 @@ "psr/http-message": "^1.0 || ^2.0", "psr/http-message-implementation": "1.0", "psr/http-server-handler": "^1.0", - "psr/http-server-middleware": "^1.0", - "yiisoft/cookies": "^1.0" + "psr/http-server-middleware": "^1.0" }, "require-dev": { "bamarni/composer-bin-plugin": "*", diff --git a/src/NullSession.php b/src/NullSession.php index 9faf51c..64dd5dc 100644 --- a/src/NullSession.php +++ b/src/NullSession.php @@ -50,7 +50,14 @@ public function destroy(): void {} public function getCookieParameters(): array { - return []; + return [ + 'lifetime' => 0, + 'path' => '/', + 'domain' => '', + 'secure' => false, + 'httponly' => false, + 'samesite' => '', + ]; } public function getId(): ?string diff --git a/src/Session.php b/src/Session.php index 24150da..c272959 100644 --- a/src/Session.php +++ b/src/Session.php @@ -12,6 +12,8 @@ /** * Session provides session data management and the related configurations. * + * @psalm-import-type CookieParameters from SessionInterface + * * @psalm-type SessionOptions = array{ * name?: string, * }&array @@ -210,6 +212,7 @@ public function destroy(): void public function getCookieParameters(): array { + /** @psalm-var CookieParameters */ return session_get_cookie_params(); } diff --git a/src/SessionInterface.php b/src/SessionInterface.php index 563285c..ea53a5a 100644 --- a/src/SessionInterface.php +++ b/src/SessionInterface.php @@ -6,6 +6,15 @@ /** * Session interface defines session data management API. + * + * @psalm-type CookieParameters = array{ + * lifetime: int, + * path: string, + * domain: string, + * secure: bool, + * httponly: bool, + * samesite: string + * } */ interface SessionInterface { @@ -109,6 +118,8 @@ public function destroy(): void; /** * @return array Parameters for a session cookie. + * + * @psalm-return CookieParameters */ public function getCookieParameters(): array; } diff --git a/src/SessionMiddleware.php b/src/SessionMiddleware.php index 7e337bf..af2bbab 100644 --- a/src/SessionMiddleware.php +++ b/src/SessionMiddleware.php @@ -5,14 +5,18 @@ namespace Yiisoft\Session; use DateInterval; +use DateTimeImmutable; +use DateTimeInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Throwable; -use Yiisoft\Cookies\Cookie; use Exception; +use function implode; +use function urlencode; + /** * Session middleware handles storing session ID into a response cookie and * restoring the session associated with the ID from a request cookie. @@ -59,46 +63,60 @@ private function commitSession(ServerRequestInterface $request, ResponseInterfac return $response; } - /** @psalm-var array{ - * lifetime: int, - * path: string, - * domain: string, - * secure: bool, - * httponly: bool, - * samesite: string - * } - */ + return $response->withAddedHeader( + 'Set-Cookie', + $this->buildSessionCookieHeader($request, $currentSessionId), + ); + } + + /** + * Build a `Set-Cookie` header value that stores the session ID. + * + * @throws Exception + */ + private function buildSessionCookieHeader(ServerRequestInterface $request, string $sessionId): string + { $cookieParameters = $this->session->getCookieParameters(); - $cookieDomain = $cookieParameters['domain']; - if (empty($cookieDomain)) { - $cookieDomain = $request - ->getUri() - ->getHost(); + $domain = $cookieParameters['domain']; + if (empty($domain)) { + $domain = $request->getUri()->getHost(); } $useSecureCookie = $cookieParameters['secure']; - if ($useSecureCookie && $request - ->getUri() - ->getScheme() !== 'https') { + if ($useSecureCookie && $request->getUri()->getScheme() !== 'https') { throw new SessionException( - '"cookie_secure" is on but connection is not secure. ' - . 'Either set Session "cookie_secure" option to "0" or make connection secure.', + '"cookie_secure" is on but connection is not secure. Either set Session "cookie_secure" option to "0" or make connection secure.', ); } - $sessionCookie = (new Cookie($this->session->getName(), $currentSessionId)) - ->withPath($cookieParameters['path']) - ->withDomain($cookieDomain) - ->withHttpOnly($cookieParameters['httponly']) - ->withSecure($useSecureCookie) - ->withSameSite($cookieParameters['samesite'] ?? Cookie::SAME_SITE_LAX); + $sameSite = $cookieParameters['samesite'] ?? 'Lax'; + + $cookieParts = [$this->session->getName() . '=' . urlencode($sessionId)]; if ($cookieParameters['lifetime'] > 0) { - $sessionCookie = $sessionCookie->withMaxAge(new DateInterval('PT' . $cookieParameters['lifetime'] . 'S')); + $expires = (new DateTimeImmutable())->add( + new DateInterval('PT' . $cookieParameters['lifetime'] . 'S'), + ); + $cookieParts[] = 'Expires=' . $expires->format(DateTimeInterface::RFC1123); + $cookieParts[] = 'Max-Age=' . $cookieParameters['lifetime']; + } + + $cookieParts[] = 'Domain=' . $domain; + $cookieParts[] = 'Path=' . $cookieParameters['path']; + + // The "Secure" flag is required for cookies marked as "SameSite=None". + if ($useSecureCookie || $sameSite === 'None') { + $cookieParts[] = 'Secure'; + } + + if ($cookieParameters['httponly']) { + $cookieParts[] = 'HttpOnly'; } - return $sessionCookie->addToResponse($response); + $cookieParts[] = 'SameSite=' . $sameSite; + + return implode('; ', $cookieParts); } private function getSessionIdFromRequest(ServerRequestInterface $request): ?string diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index a8c6137..7b48df2 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -38,9 +38,7 @@ private function createContainer(?array $params = null): Container private function getDiConfig(?array $params = null): array { - if ($params === null) { - $params = $this->getParams(); - } + $params ??= $this->getParams(); return require dirname(__DIR__) . '/config/di-web.php'; } diff --git a/tests/SessionMiddlewareTest.php b/tests/SessionMiddlewareTest.php index 7de1eba..d8bc653 100644 --- a/tests/SessionMiddlewareTest.php +++ b/tests/SessionMiddlewareTest.php @@ -126,6 +126,98 @@ public function testManualCloseSession(): void $this->assertNotSame($response, $result); } + public function testProcessSetsSessionCookieWithAllParameters(): void + { + $this->setUpSessionMock(true, false, 'new_session_id'); + $this->setUpRequestMock(true, null); + + $response = new Response(); + $this->setUpRequestHandlerMock($response); + + $result = $this->sessionMiddleware->process($this->requestMock, $this->requestHandlerMock); + + $this->assertMatchesRegularExpression( + '~^exampleSessionName=new_session_id; Expires=[A-Za-z0-9,:+ ]+; Max-Age=3600; Domain=exampleDomain; Path=examplePath; Secure; HttpOnly; SameSite=Strict$~', + $result->getHeaderLine('Set-Cookie'), + ); + } + + public function testProcessEncodesSessionCookieValue(): void + { + $this->setUpSessionMock(true, false, 'value with spaces'); + $this->setUpRequestMock(true, null); + + $response = new Response(); + $this->setUpRequestHandlerMock($response); + + $result = $this->sessionMiddleware->process($this->requestMock, $this->requestHandlerMock); + + $this->assertStringStartsWith('exampleSessionName=value+with+spaces;', $result->getHeaderLine('Set-Cookie')); + } + + public function testProcessOmitsExpiresAndMaxAgeWhenLifetimeIsZero(): void + { + $this->setUpSessionMock(true, false, 'new_session_id', ['lifetime' => 0]); + $this->setUpRequestMock(true, null); + + $response = new Response(); + $this->setUpRequestHandlerMock($response); + + $result = $this->sessionMiddleware->process($this->requestMock, $this->requestHandlerMock); + + $this->assertSame( + 'exampleSessionName=new_session_id; Domain=exampleDomain; Path=examplePath; Secure; HttpOnly; SameSite=Strict', + $result->getHeaderLine('Set-Cookie'), + ); + } + + public function testProcessOmitsHttpOnlyWhenDisabled(): void + { + $this->setUpSessionMock(true, false, 'new_session_id', ['httponly' => false]); + $this->setUpRequestMock(true, null); + + $response = new Response(); + $this->setUpRequestHandlerMock($response); + + $result = $this->sessionMiddleware->process($this->requestMock, $this->requestHandlerMock); + + $cookieHeader = $result->getHeaderLine('Set-Cookie'); + $this->assertStringNotContainsString('HttpOnly', $cookieHeader); + $this->assertStringEndsWith('; Secure; SameSite=Strict', $cookieHeader); + } + + public function testProcessForcesSecureFlagWhenSameSiteIsNone(): void + { + $this->setUpSessionMock(true, false, 'new_session_id', ['samesite' => 'None', 'secure' => false]); + $this->setUpRequestMock(false, null); + + $response = new Response(); + $this->setUpRequestHandlerMock($response); + + $result = $this->sessionMiddleware->process($this->requestMock, $this->requestHandlerMock); + + $cookieHeader = $result->getHeaderLine('Set-Cookie'); + $this->assertStringContainsString('; Secure; ', $cookieHeader); + $this->assertStringEndsWith('SameSite=None', $cookieHeader); + } + + public function testProcessUsesRequestHostAsCookieDomainWhenDomainNotProvided(): void + { + $this->setUpSessionMock(false, false, 'new_session_id'); + $this->setUpRequestMock(true, null); + + $this->uriMock + ->method('getHost') + ->willReturn('example.com'); + + $response = new Response(); + $this->setUpRequestHandlerMock($response); + + $result = $this->sessionMiddleware->process($this->requestMock, $this->requestHandlerMock); + + $this->assertStringContainsString('; Domain=example.com; ', $result->getHeaderLine('Set-Cookie')); + } + private function setUpRequestHandlerMock(ResponseInterface $response): void { $this->requestHandlerMock @@ -138,6 +230,7 @@ private function setUpSessionMock( bool $cookieDomainProvided = true, bool $isActive = true, ?string $sessionId = self::CURRENT_SID, + array $cookieParametersOverride = [], ): void { $this->sessionMock ->expects($this->any()) @@ -154,7 +247,7 @@ private function setUpSessionMock( ->method('getID') ->willReturn($sessionId); - $cookieParams = self::COOKIE_PARAMETERS; + $cookieParams = array_merge(self::COOKIE_PARAMETERS, $cookieParametersOverride); if (!$cookieDomainProvided) { $cookieParams['domain'] = ''; } diff --git a/tests/SessionTest.php b/tests/SessionTest.php index 97efff2..5cb781a 100644 --- a/tests/SessionTest.php +++ b/tests/SessionTest.php @@ -28,9 +28,7 @@ protected function tearDown(): void public function getSession(array $options = [], ?SessionHandlerInterface $handler = null): Session { - if ($this->session === null) { - $this->session = new Session($options, $handler); - } + $this->session ??= new Session($options, $handler); return $this->session; }