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
19 changes: 19 additions & 0 deletions docs/1-essentials/01-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,25 @@ final readonly class ErrorResponseProcessor implements ResponseProcessor
}
```

## Cookie management

### Configuration

By default, Tempest encrypts all cookies it sets, and discards any incoming cookie it cannot decrypt.
This behaviour can be configured by creating a `cookie.config.php` file [anywhere](../1-essentials/06-configuration.md#configuration-files).

```php app/cookie.config.php
use Tempest\Http\Cookie\CookieConfig;

return new CookieConfig(
plaintextCookies: ['darkmode'],
);
```

**`discardUnencryptedCookies`** — When `true` (default), any incoming cookie that Tempest cannot decrypt will be discarded and the browser instructed to delete it. Set to `false` to silently ignore unencrypted cookies instead, leaving them intact in the browser. Note that either way, unencrypted cookies will not be accessible in the request object unless whitelisted via `plaintextCookies`.

**`plaintextCookies`** — A list of cookie names that Tempest will not attempt to encrypt or decrypt. Whitelisted cookies are preserved in the browser, accessible in the request object, and sent to the browser in plaintext. Useful for cookies set by third-party services such as reverse proxies or CDNs, or cookies that must be readable by JavaScript (e.g. UI preferences like dark mode).

## Session management

Sessions in Tempest are managed by the {b`Tempest\Http\Session\Session`} class. It can be injected anywhere needed. As soon as the {b`Tempest\Http\Session\Session`} is injected, it is started behind the scenes.
Expand Down
24 changes: 24 additions & 0 deletions packages/http/src/Cookie/CookieConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Tempest\Http\Cookie;

final class CookieConfig
{
public function __construct(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you add docblocks to describe the functionality of each option?

/**
* Whether to discard cookies that cannot be decrypted.
* What this means: any cookies not encrypted by your application (or not whitelisted) that
* arrive with a request, will prompt tempest to request the browser to forget these cookies.
* Cookies sent unencrypted and not whitelisted will also not be available in the request object.
*/
public bool $discardUnencryptedCookies = true,

/**
* List of cookies that will not be decrypted by tempest, be available in the request object.
* Outgoing whitelisted cookies will be sent to the browser in plaintext.
*/
public array $plaintextCookies = [],
) {}
}
14 changes: 11 additions & 3 deletions packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Psr\Http\Message\UploadedFileInterface;
use Tempest\Cryptography\Encryption\Encrypter;
use Tempest\Http\Cookie\Cookie;
use Tempest\Http\Cookie\CookieConfig;
use Tempest\Http\Cookie\CookieManager;
use Tempest\Http\GenericRequest;
use Tempest\Http\Method;
Expand All @@ -25,6 +26,7 @@
public function __construct(
private Encrypter $encrypter,
private CookieManager $cookies,
private CookieConfig $cookieConfig,
) {}

public function canMap(mixed $from, mixed $to): bool
Expand Down Expand Up @@ -65,14 +67,20 @@ public function map(mixed $from, mixed $to): GenericRequest
'files' => $uploads,
'cookies' => Arr\filter(Arr\map(
array: $_COOKIE,
map: function (string $value, string $key) {
map: function (string $rawValue, string $key) {
try {
$value = \in_array($key, $this->cookieConfig->plaintextCookies, true)
? $rawValue
: $this->encrypter->decrypt($rawValue);

return new Cookie(
key: $key,
value: $this->encrypter->decrypt($value),
value: $value,
);
} catch (Throwable) {
$this->cookies->remove($key);
if ($this->cookieConfig->discardUnencryptedCookies) {
$this->cookies->remove($key);
}

return null;
}
Expand Down
10 changes: 7 additions & 3 deletions packages/router/src/SetCookieHeadersMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Tempest\Router;

use Tempest\Cryptography\Encryption\Encrypter;
use Tempest\Http\Cookie\CookieConfig;
use Tempest\Http\Cookie\CookieManager;
use Tempest\Http\Request;
use Tempest\Http\Response;
Expand All @@ -19,16 +20,19 @@
public function __construct(
private Encrypter $encrypter,
private CookieManager $cookies,
private CookieConfig $cookieConfig,
) {}

public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
{
$response = $next($request);

foreach ($this->cookies->all() as $cookie) {
$cookieValue = $cookie->value === ''
? ''
: $this->encrypter->encrypt($cookie->value)->serialize();
$cookieValue = match (true) {
$cookie->value === '' => '',
\in_array($cookie->key, $this->cookieConfig->plaintextCookies, true) => $cookie->value,
default => $this->encrypter->encrypt($cookie->value)->serialize(),
};

$response->addHeader('set-cookie', (string) $cookie->withValue($cookieValue));
}
Expand Down
198 changes: 198 additions & 0 deletions tests/Integration/Http/CookieHandlingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<?php

declare(strict_types=1);

namespace Integration\Http;

use PHPUnit\Framework\Attributes\Test;
use ReflectionClass;
use Tempest\Cryptography\Encryption\Encrypter;
use Tempest\Http\Cookie\Cookie;
use Tempest\Http\Cookie\CookieConfig;
use Tempest\Http\Request;
use Tempest\Http\Responses\Ok;
use Tempest\Reflection\MethodReflector;
use Tempest\Router\Get;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;

final class CookieHandlingTest extends FrameworkIntegrationTestCase
{
#[Test]
public function encrypted_cookies_are_kept_when_default(): void
{
try {
$encrypter = $this->container->get(Encrypter::class);
$_COOKIE['Cookie_name'] = $encrypter->encrypt('myCookieValue')->serialize();

$responseHelper = $this->http
->registerRoute($this->returnCookieValueController())
->get('/get_cookie_value')
->assertOk()
->assertSee('myCookieValue');

foreach ($responseHelper->headers as $header) {
if ($header->name !== 'set-cookie') {
continue;
}

foreach ($header->values as $value) {
$this->assertNotEquals(
$value,
'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax',
);
}
}
} finally {
unset($_COOKIE['Cookie_name']);
}
}

#[Test]
public function unencrypted_cookies_are_discarded_when_default(): void
{
try {
$_COOKIE['Cookie_name'] = 'myCookieValue';

$this->http
->registerRoute($this->returnCookieValueController())
->get('/get_cookie_value')
->assertOk()
->assertHeaderMatches('set-cookie', 'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax')
->assertNotSee('myCookieValue');
} finally {
unset($_COOKIE['Cookie_name']);
}
}

#[Test]
public function unencrypted_cookies_are_kept_when_discard_false(): void
{
$this->container->config(new CookieConfig(discardUnencryptedCookies: false));

try {
$_COOKIE['Cookie_name'] = 'myCookieValue';

$responseHelper = $this->http
->registerRoute($this->returnCookieValueController())
->get('/get_cookie_value')
->assertOk()
->assertNotSee('myCookieValue'); // cookies are not discarded but not whitelisted so not available

foreach ($responseHelper->headers as $header) {
if ($header->name !== 'set-cookie') {
continue;
}

foreach ($header->values as $value) {
$this->assertNotEquals(
$value,
'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax',
);
}
}
} finally {
unset($_COOKIE['Cookie_name']);
}
}

#[Test]
public function unencrypted_cookies_are_discarded_when_discard_true(): void
{
$this->container->config(new CookieConfig(discardUnencryptedCookies: true));

try {
$_COOKIE['Cookie_name'] = 'myCookieValue';

$this->http
->registerRoute($this->returnCookieValueController())
->get('/get_cookie_value')
->assertOk()
->assertHeaderMatches('set-cookie', 'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax')
->assertNotSee('myCookieValue');
} finally {
unset($_COOKIE['Cookie_name']);
}
}

#[Test]
public function whitelisted_plaintext_cookies_are_kept(): void
{
$this->container->config(new CookieConfig(
discardUnencryptedCookies: true,
plaintextCookies: ['Cookie_name'],
));

try {
$_COOKIE['Cookie_name'] = 'myCookieValue';

$responseHelper = $this->http
->registerRoute($this->returnCookieValueController())
->get('/get_cookie_value')
->assertOk()
->assertSee('myCookieValue');

foreach ($responseHelper->headers as $header) {
if ($header->name !== 'set-cookie') {
continue;
}

foreach ($header->values as $value) {
$this->assertNotEquals(
$value,
'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax',
);
}
}
} finally {
unset($_COOKIE['Cookie_name']);
}
}

#[Test]
public function whitelisted_plaintext_cookies_are_send_in_plain(): void
{
$this->container->config(new CookieConfig(
plaintextCookies: ['Cookie_name'],
));

$controller = new class {
#[Get('/test_whitelisted_unencrypted_cookies_are_send_in_plain')]
public function __invoke(): Ok
{
return new Ok()->addCookie(
new Cookie(
key: 'Cookie_name',
value: 'value',
),
);
}
};

$reflection = new ReflectionClass($controller);
$method = $reflection->getMethod('__invoke');

$this->http
->registerRoute(new MethodReflector($method))
->get('/test_whitelisted_unencrypted_cookies_are_send_in_plain')
->assertOk()
->assertHeaderMatches('set-cookie', 'Cookie_name=value; Path=/; Secure; SameSite=Lax');
}

private function returnCookieValueController(): MethodReflector
{
$controller = new class() {
#[Get('/get_cookie_value')]
public function __invoke(Request $request): Ok
{
return new Ok(
$request->getCookie('Cookie_name')->value ?? '',
);
}
};

$reflection = new ReflectionClass($controller);
$method = $reflection->getMethod('__invoke');

return new MethodReflector($method);
}
}
Loading