Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 1 addition & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*",
Expand Down
9 changes: 8 additions & 1 deletion src/NullSession.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,mixed>
Expand Down Expand Up @@ -210,6 +212,7 @@ public function destroy(): void

public function getCookieParameters(): array
{
/** @psalm-var CookieParameters */
return session_get_cookie_params();
}

Expand Down
11 changes: 11 additions & 0 deletions src/SessionInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -109,6 +118,8 @@ public function destroy(): void;

/**
* @return array Parameters for a session cookie.
*
* @psalm-return CookieParameters
*/
public function getCookieParameters(): array;
}
74 changes: 46 additions & 28 deletions src/SessionMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)];
Comment on lines +93 to +95

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We assume a SessionInterface implementation returns valid cookie parameters, so validating them here is out of scope.

In the common case (built-in SessionInterface implementation) cookie parameters come from PHP's own session configuration (session_get_cookie_params(), session_name()), and the cookie value is urlencode().


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
Expand Down
4 changes: 1 addition & 3 deletions tests/ConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
95 changes: 94 additions & 1 deletion tests/SessionMiddlewareTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
Expand All @@ -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'] = '';
}
Expand Down
4 changes: 1 addition & 3 deletions tests/SessionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading