diff --git a/.coveralls.yml b/.coveralls.yml
new file mode 100644
index 0000000..bc71b62
--- /dev/null
+++ b/.coveralls.yml
@@ -0,0 +1,2 @@
+coverage_clover: clover.xml
+json_path: coveralls-upload.json
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5cf9a2c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/composer.lock
+/phpunit.xml
+/vendor/
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..698945a
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,41 @@
+sudo: false
+
+language: php
+
+cache:
+ directories:
+ - $HOME/.composer/cache
+ - $HOME/.local
+ - vendor
+
+matrix:
+ fast_finish: true
+ include:
+ - php: 7.1
+ env:
+ - EXECUTE_CS_CHECK=true
+ - EXECUTE_TEST_COVERALLS=true
+ - PATH="$HOME/.local/bin:$PATH"
+ - php: nightly
+ allow_failures:
+ - php: nightly
+
+before_install:
+ - if [[ $EXECUTE_TEST_COVERALLS != 'true' ]]; then phpenv config-rm xdebug.ini || return 0 ; fi
+ - composer self-update
+ - if [[ $EXECUTE_TEST_COVERALLS == 'true' ]]; then composer require --dev --no-update php-coveralls/php-coveralls:2.0.0 ; fi
+
+install:
+ - travis_retry composer install --no-interaction
+ - composer info -i
+
+script:
+ - if [[ $EXECUTE_TEST_COVERALLS == 'true' ]]; then composer test-coverage ; fi
+ - if [[ $EXECUTE_TEST_COVERALLS != 'true' ]]; then composer test ; fi
+ - if [[ $EXECUTE_CS_CHECK == 'true' ]]; then composer cs ; fi
+
+after_script:
+ - if [[ $EXECUTE_TEST_COVERALLS == 'true' ]]; then composer coveralls ; fi
+
+notifications:
+ email: true
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..2c22ee0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+Copyright (c) 2018, Ben Scholzen (DASPRiD)
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..cfabb75
--- /dev/null
+++ b/README.md
@@ -0,0 +1,102 @@
+# Pikkuleipa
+
+[![Build Status](https://travis-ci.org/DASPRiD/Pikkuleipa.svg?branch=master)](https://travis-ci.org/DASPRiD/Pikkuleipa)
+[![Coverage Status](https://coveralls.io/repos/github/DASPRiD/Pikkuleipa/badge.svg?branch=master)](https://coveralls.io/github/DASPRiD/Pikkuleipa?branch=master)
+[![Latest Stable Version](https://poser.pugx.org/dasprid/pikkuleipa/v/stable)](https://packagist.org/packages/dasprid/pikkuleipa)
+[![Total Downloads](https://poser.pugx.org/dasprid/pikkuleipa/downloads)](https://packagist.org/packages/dasprid/pikkuleipa)
+[![License](https://poser.pugx.org/dasprid/pikkuleipa/license)](https://packagist.org/packages/dasprid/pikkuleipa)
+
+Pikkuleipa is a cookie manager for PSR-7 compliant applications, utilizing [JSON Web Tokens](https://jwt.io/) for
+security and allowing the handling of multiple independent cookies.authentication middleware embracing PSR-7.
+
+## Installation
+
+Install via composer:
+
+```bash
+$ composer require dasprid/pikkuleipa
+```
+
+## Getting started (for [Expressive](https://github.com/zendframework/zend-expressive))
+
+### Import the factory config
+
+Create a file named `pikkuleipa.global.php` or similar in your autoloading config directory:
+
+```php
+__invoke();
+```
+
+This will introduce a few factories, namely you can retrieve the following objects through that:
+
+- `DASPRiD\Pikkuleipa\CookieManager` through `DASPRiD\Pikkuleipa\CookieManagerInterface`
+- `DASPRiD\Pikkuleipa\TokenManager` through `DASPRiD\Pikkuleipa\TokenManagerInterface`
+
+### Configure Pikkuleipa
+
+For Pikkuleipa to function, it needs a few configuration variables. Copy the file `doc/example-config.php` and adjust the
+values as needed.
+
+### Using the cookie manager
+
+The token manager should usually not be of interest to you. The important part is the cookie manager, which you can
+either use through the container, if you are using PSR/Container, or by other means. It concretely gives you three
+actions you can do, which are setting cookies, getting cookies and expiring cookies.
+
+#### Setting cookies
+
+Setting a cookie is really easy. First you either get an existing cookie from the cookie manager or you create a new
+one. Then you set that cookie on a PSR-7 response and return the modified response to the user.
+
+The `setCookie` method takes two additional parameters beside the response and the cookie. The first one is whether the
+cookie should expire at the end of the browser session, which defaults to false. The second one defines whether the
+`setCookie` call should override a previous `expireCookie` call, which defaults to true.
+
+```php
+get(CookieManagerInterface::class);
+$cookie = new Cookie('foo');
+$cookie->set('bar', 'baz');
+
+$newResponse = $cookieManager->setCookie($response, $cookie);
+```
+
+#### Getting cookies
+
+Getting cookies is also quite simple. When retrieving a cookie, the cookie- and the token manager will verify that the
+cookie exists and its contents are legit. If something fails, a new empty cookie instance is returned.
+
+```php
+get(CookieManagerInterface::class);
+$cookie = $cookieManager->getCookie($serverRequest, 'foo');
+
+echo $cookie->get('bar'); // Outputs: bar
+```
+
+#### Expiring cookies
+
+Expiring cookies is just as simple as setting a cookie. You can either expire a cookie by its instance or by name:
+
+```php
+get(CookieManagerInterface::class);
+$cookie = $cookieManager->getCookie($serverRequest, 'foo');
+
+$newResponse = $cookieManager->expireCookie($cookie);
+
+// Or:
+$newResponse = $cookieManager->expireCookieByName('foo');
+```
+
+## About the name
+
+Pikkuleipa is the Finnish word for "cookie" or "biscuit", nothing fancy here!
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..6501b9a
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,60 @@
+{
+ "name": "dasprid/pikkuleipa",
+ "description": "PSR-7 JWT cookie handler",
+ "type": "library",
+ "require": {
+ "php": "^7.1",
+ "lcobucci/jwt": "^3.2",
+ "psr/http-message": "^1.0",
+ "dflydev/fig-cookies": "^1.0",
+ "cultuurnet/clock": "^1.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^5.5",
+ "psr/container": "^1.0",
+ "dasprid/treereader": "^1.3",
+ "zendframework/zend-diactoros": "^1.3",
+ "squizlabs/php_codesniffer": "^2.7"
+ },
+ "suggest": {
+ "psr/container": "For using the supplied factories",
+ "dasprid/treereader": "For using the supplied factories"
+ },
+ "license": "BSD-2-Clause",
+ "authors": [
+ {
+ "name": "Ben Scholzen 'DASPRiD'",
+ "homepage": "https://dasprids.de/",
+ "email": "mail@dasprids.de"
+ }
+ ],
+ "keywords": [
+ "jwt",
+ "cookie",
+ "session",
+ "http",
+ "psr",
+ "psr-7"
+ ],
+ "autoload": {
+ "psr-4": {
+ "DASPRiD\\Pikkuleipa\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "DASPRiD\\PikkuleipaTest\\": "test/"
+ }
+ },
+ "scripts": {
+ "check": [
+ "@cs",
+ "@test"
+ ],
+ "coveralls": "php-coveralls",
+ "cs": "phpcs",
+ "cs-fix": "phpcbf",
+ "test": "phpunit",
+ "test-coverage": "phpunit --coverage-clover clover.xml"
+ }
+}
diff --git a/doc/example-config.php b/doc/example-config.php
new file mode 100644
index 0000000..750d555
--- /dev/null
+++ b/doc/example-config.php
@@ -0,0 +1,33 @@
+ [
+ 'default_cookie_settings' => [
+ // Path which the cookie applies to
+ 'path' => '/',
+
+ // Whether the cookie is limited to HTTPS
+ 'secure' => true,
+
+ // Lifetime of the cookie, here 30 days
+ 'lifetime' => 2592000,
+ ],
+
+ 'cookie_settings' => [
+ // Here you can configure all the different cookies you are using
+ 'some_cookie_name' => [
+ 'path' => '/',
+ 'secure' => true,
+ 'lifetime' => 60
+ ],
+ ],
+
+ 'token' => [
+ // Signer used for signing and verification
+ 'signer_class' => Lcobucci\JWT\Signer\Rsa\Sha256::class,
+
+ // Signature and verification keys. See: https://github.com/lcobucci/jwt#token-signature
+ 'signature_key' => '',
+ 'verification_key' => '',
+ ],
+ ],
+];
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
index 0000000..3134b1f
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,21 @@
+
+
+ Pikkuleipa coding standard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ src
+ test
+
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..fb89fe8
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,17 @@
+
+
+
+
+ ./test
+
+
+
+
+
+ src
+
+
+
diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php
new file mode 100644
index 0000000..7643a2f
--- /dev/null
+++ b/src/ConfigProvider.php
@@ -0,0 +1,27 @@
+ $this->getDependencyConfig(),
+ ];
+ }
+
+ public function getDependencyConfig() : array
+ {
+ return [
+ 'factories' => [
+ CookieManagerInterface::class => CookieManagerFactory::class,
+ TokenManagerInterface::class => TokenManagerFactory::class,
+ ],
+ ];
+ }
+}
diff --git a/src/Cookie.php b/src/Cookie.php
new file mode 100644
index 0000000..cc25b9d
--- /dev/null
+++ b/src/Cookie.php
@@ -0,0 +1,113 @@
+name = $name;
+ $this->issuedAt = $issuedAt ?: new DateTimeImmutable();
+ }
+
+ /**
+ * Creates a new cookie instance from JSON encoded data.
+ *
+ * This is primarily used by the `TokenManager` to unserialize a cookie from a JWT token.
+ *
+ * @throws JsonException When an error occurs during decoding
+ */
+ public static function fromJson(string $name, DateTimeImmutable $issuedAt, string $json) : self
+ {
+ $cookie = new self($name, $issuedAt);
+ $cookie->data = json_decode($json, true);
+
+ if (! is_array($cookie->data)) {
+ throw JsonException::fromJsonDecodeError(json_last_error_msg());
+ }
+
+ return $cookie;
+ }
+
+ /**
+ * Serializes the cookie data as JSON.
+ *
+ * @throws JsonException When an error occurs during encoding
+ */
+ public function toJson() : string
+ {
+ $json = json_encode($this->data);
+
+ if (false === $json) {
+ throw JsonException::fromJsonEncodeError(json_last_error_msg());
+ }
+
+ return $json;
+ }
+
+ /**
+ * Returns the name of the cookie.
+ */
+ public function getName() : string
+ {
+ return $this->name;
+ }
+
+ /**
+ * Returns when the cookie what issued.
+ */
+ public function getIssuedAt() : DateTimeImmutable
+ {
+ return $this->issuedAt;
+ }
+
+ /**
+ * Returns a value from the cookie, or null if it doesn't exist.
+ *
+ * @return mixed
+ */
+ public function get(string $name)
+ {
+ return $this->data[$name] ?? null;
+ }
+
+ /**
+ * Sets a value in the cookie.
+ *
+ * The value can be any scalar, array or object, which implements the `JsonSerializable` interface. Other values
+ * will lead to an exception when trying to encode it for the response.
+ *
+ * @param mixed $value
+ */
+ public function set(string $name, $value) : void
+ {
+ $this->data[$name] = $value;
+ }
+
+ /**
+ * Removes a value from the cookie.
+ */
+ public function remove(string $name) : void
+ {
+ unset($this->data[$name]);
+ }
+}
diff --git a/src/CookieManager.php b/src/CookieManager.php
new file mode 100644
index 0000000..6738b96
--- /dev/null
+++ b/src/CookieManager.php
@@ -0,0 +1,118 @@
+defaultCookieSettings = $defaultCookieSettings;
+ $this->tokenManager = $tokenManager;
+ $this->clock = $clock ?: new SystemClock(new DateTimeZone('UTC'));
+ }
+
+ public function withCookieSettings(string $cookieName, CookieSettings $cookieSettings) : self
+ {
+ $cookieManager = new self($this->defaultCookieSettings, $this->tokenManager, $this->clock);
+ $cookieManager->cookieSettings = $this->cookieSettings;
+ $cookieManager->cookieSettings[$cookieName] = $cookieSettings;
+ return $cookieManager;
+ }
+
+ public function getCookie(ServerRequestInterface $request, string $cookieName) : Cookie
+ {
+ $requestCookie = FigRequestCookies::get($request, $cookieName);
+ $cookieValue = $requestCookie->getValue();
+
+ if (null === $cookieValue) {
+ return new Cookie($cookieName);
+ }
+
+ $cookie = $this->tokenManager->parseSignedToken($cookieValue);
+
+ if (null === $cookie || $cookie->getName() !== $cookieName) {
+ return new Cookie($cookieName);
+ }
+
+ return $cookie;
+ }
+
+ public function setCookie(
+ ResponseInterface $response,
+ Cookie $cookie,
+ bool $endWithSession = false,
+ bool $overwriteExpireCookie = true
+ ) : ResponseInterface {
+ $cookieName = $cookie->getName();
+
+ if (! $overwriteExpireCookie && 1 === FigResponseCookies::get($response, $cookieName)->getExpires()) {
+ return $response;
+ }
+
+ $cookieSettings = $this->cookieSettings[$cookieName] ?? $this->defaultCookieSettings;
+ $currentTimestamp = $this->clock->getDateTime()->getTimestamp();
+ $setCookie = SetCookie::create($cookieName)
+ ->withHttpOnly(true)
+ ->withPath($cookieSettings->getPath())
+ ->withExpires($endWithSession ? null : $currentTimestamp + $cookieSettings->getLifetime())
+ ->withSecure($cookieSettings->isSecure());
+
+ return FigResponseCookies::set(
+ $response,
+ $setCookie->withValue(
+ $this->tokenManager->getSignedToken($cookie, $endWithSession ? null : $cookieSettings->getLifetime())
+ )
+ );
+ }
+
+ public function expireCookie(ResponseInterface $response, Cookie $cookie) : ResponseInterface
+ {
+ return $this->expireCookieByName($response, $cookie->getName());
+ }
+
+ public function expireCookieByName(ResponseInterface $response, string $cookieName) : ResponseInterface
+ {
+ $cookieSettings = $this->cookieSettings[$cookieName] ?? $this->defaultCookieSettings;
+ $setCookie = SetCookie::create($cookieName)
+ ->withHttpOnly(true)
+ ->withPath($cookieSettings->getPath())
+ ->withExpires(1)
+ ->withSecure($cookieSettings->isSecure())
+ ->withValue('');
+
+ return FigResponseCookies::set($response, $setCookie);
+ }
+}
diff --git a/src/CookieManagerInterface.php b/src/CookieManagerInterface.php
new file mode 100644
index 0000000..80a64c1
--- /dev/null
+++ b/src/CookieManagerInterface.php
@@ -0,0 +1,43 @@
+path = $path;
+ $this->secure = $secure;
+ $this->lifetime = $lifetime;
+ }
+
+ /**
+ * Gets the path for the cookie.
+ */
+ public function getPath() : string
+ {
+ return $this->path;
+ }
+
+ /**
+ * Returns whether the cookie should be marked as HTTPS only.
+ */
+ public function isSecure() : bool
+ {
+ return $this->secure;
+ }
+
+ /**
+ * Gets the lifetime of the cookie in seconds.
+ */
+ public function getLifetime() : int
+ {
+ return $this->lifetime;
+ }
+}
diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php
new file mode 100644
index 0000000..88ff948
--- /dev/null
+++ b/src/Exception/ExceptionInterface.php
@@ -0,0 +1,10 @@
+get('config')))->getChildren('pikkuleipa');
+
+ $cookieManager = new CookieManager(
+ $this->createCookieSettings($config->getChildren('default_cookie_settings')),
+ $container->get(TokenManagerInterface::class)
+ );
+
+ if (! $config->hasKey('cookie_settings')) {
+ return $cookieManager;
+ }
+
+ foreach ($config->getChildren('cookie_settings') as $cookieSettings) {
+ $cookieManager = $cookieManager->withCookieSettings(
+ $cookieSettings->getKey(),
+ $this->createCookieSettings($cookieSettings->getChildren())
+ );
+ }
+
+ return $cookieManager;
+ }
+
+ private function createCookieSettings(TreeReader $config) : CookieSettings
+ {
+ return new CookieSettings(
+ $config->getString('path'),
+ $config->getBool('secure'),
+ $config->getInt('lifetime')
+ );
+ }
+}
diff --git a/src/Factory/TokenManagerFactory.php b/src/Factory/TokenManagerFactory.php
new file mode 100644
index 0000000..891b67e
--- /dev/null
+++ b/src/Factory/TokenManagerFactory.php
@@ -0,0 +1,25 @@
+get('config')))->getChildren('pikkuleipa')->getChildren('token');
+
+ $signerClass = $config->getString('signer_class');
+
+ return new TokenManager(
+ new $signerClass(),
+ $config->getString('signature_key'),
+ $config->getString('verification_key')
+ );
+ }
+}
diff --git a/src/TokenManager.php b/src/TokenManager.php
new file mode 100644
index 0000000..b39a5f7
--- /dev/null
+++ b/src/TokenManager.php
@@ -0,0 +1,103 @@
+signer = $signer;
+ $this->signatureKey = $signatureKey;
+ $this->verificationKey = $verificationKey;
+ $this->tokenParser = $tokenParser ?: new Parser();
+ $this->clock = $clock ?: new SystemClock(new DateTimeZone('UTC'));
+ }
+
+ public function getSignedToken(Cookie $cookie, ?int $lifetime = null) : string
+ {
+ $currentTimestamp = $this->clock->getDateTime()->getTimestamp();
+ $builder = (new Builder())
+ ->setIssuedAt($currentTimestamp)
+ ->setSubject($cookie->getName())
+ ->set('dat', $cookie->toJson());
+
+ if (null !== $lifetime) {
+ $builder->setExpiration($currentTimestamp + $lifetime);
+ }
+
+ return (string) $builder->sign($this->signer, $this->signatureKey)->getToken();
+ }
+
+ public function parseSignedToken(string $serializedToken) : ?Cookie
+ {
+ try {
+ $token = $this->tokenParser->parse($serializedToken);
+ } catch (Exception $e) {
+ return null;
+ }
+
+ if (! $token->validate(new ValidationData($this->clock->getDateTime()->getTimestamp()))) {
+ return null;
+ }
+
+ if (! $token->verify($this->signer, $this->verificationKey)) {
+ return null;
+ }
+
+ if (! $token->hasClaim('sub') || ! $token->hasClaim('iat') || ! $token->hasClaim('dat')) {
+ return null;
+ }
+
+ try {
+ return Cookie::fromJson(
+ $token->getClaim('sub'),
+ new DateTimeImmutable('@' . $token->getClaim('iat')),
+ $token->getClaim('dat')
+ );
+ } catch (JsonException $e) {
+ return null;
+ }
+ }
+}
diff --git a/src/TokenManagerInterface.php b/src/TokenManagerInterface.php
new file mode 100644
index 0000000..408d530
--- /dev/null
+++ b/src/TokenManagerInterface.php
@@ -0,0 +1,19 @@
+assertSame([
+ 'dependencies' => (new ConfigProvider())->getDependencyConfig(),
+ ], (new ConfigProvider())->__invoke());
+ }
+
+ public function testGetDependencyConfig() : void
+ {
+ $dependencyConfig = (new ConfigProvider())->getDependencyConfig();
+ $this->assertArrayHasKey('factories', $dependencyConfig);
+ $this->assertArrayHasKey(CookieManagerInterface::class, $dependencyConfig['factories']);
+ $this->assertArrayHasKey(TokenManagerInterface::class, $dependencyConfig['factories']);
+ }
+}
diff --git a/test/CookieManagerTest.php b/test/CookieManagerTest.php
new file mode 100644
index 0000000..0ba8e88
--- /dev/null
+++ b/test/CookieManagerTest.php
@@ -0,0 +1,166 @@
+prophesize(TokenManagerInterface::class);
+ $tokenManager->getSignedToken($cookie, 100)->willReturn('bar');
+ $cookieManager = $this->createCookieManager($tokenManager->reveal());
+
+ $originalResponse = new EmptyResponse();
+ $newResponse = $cookieManager->setCookie(
+ $originalResponse,
+ $cookie,
+ false
+ );
+
+ $this->assertSame([
+ 'foo=bar; Path=/foo; Expires=Thu, 01 Jan 1970 00:03:20 GMT; Secure; HttpOnly',
+ ], $newResponse->getHeader('Set-Cookie'));
+ }
+
+ public function testSetNonSecureCookie()
+ {
+ $cookie = new Cookie('foo');
+ $tokenManager = $this->prophesize(TokenManagerInterface::class);
+ $tokenManager->getSignedToken($cookie, 100)->willReturn('bar');
+ $cookieManager = $this->createCookieManager($tokenManager->reveal(), false);
+
+ $originalResponse = new EmptyResponse();
+ $newResponse = $cookieManager->setCookie(
+ $originalResponse,
+ $cookie,
+ false
+ );
+
+ $this->assertSame([
+ 'foo=bar; Path=/foo; Expires=Thu, 01 Jan 1970 00:03:20 GMT; HttpOnly',
+ ], $newResponse->getHeader('Set-Cookie'));
+ }
+
+ public function testSetCookieExpiringEndOfSession()
+ {
+ $cookie = new Cookie('foo');
+ $tokenManager = $this->prophesize(TokenManagerInterface::class);
+ $tokenManager->getSignedToken($cookie, null)->willReturn('bar');
+ $cookieManager = $this->createCookieManager($tokenManager->reveal(), false);
+
+ $originalResponse = new EmptyResponse();
+ $newResponse = $cookieManager->setCookie(
+ $originalResponse,
+ $cookie,
+ true
+ );
+
+ $this->assertSame([
+ 'foo=bar; Path=/foo; HttpOnly',
+ ], $newResponse->getHeader('Set-Cookie'));
+ }
+
+ public function testExpireCookieIsNotOverwrittenWithSetFlag()
+ {
+ $cookie = new Cookie('foo');
+ $tokenManager = $this->prophesize(TokenManagerInterface::class);
+ $tokenManager->getSignedToken($cookie, 100)->willReturn('bar');
+ $cookieManager = $this->createCookieManager($tokenManager->reveal(), false);
+
+ $originalResponse = new EmptyResponse();
+ $expireResponse = $cookieManager->expireCookie($originalResponse, $cookie);
+ $newResponse = $cookieManager->setCookie(
+ $expireResponse,
+ $cookie,
+ false,
+ false
+ );
+
+ $this->assertSame([
+ 'foo=; Path=/foo; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly',
+ ], $newResponse->getHeader('Set-Cookie'));
+ }
+
+ public function testSecureExpireCookie()
+ {
+ $cookie = new Cookie('foo');
+ $tokenManager = $this->prophesize(TokenManagerInterface::class);
+ $tokenManager->getSignedToken($cookie, 100)->willReturn('bar');
+ $cookieManager = $this->createCookieManager($tokenManager->reveal(), true);
+
+ $originalResponse = new EmptyResponse();
+ $newResponse = $cookieManager->expireCookie($originalResponse, $cookie);
+
+ $this->assertSame([
+ 'foo=; Path=/foo; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Secure; HttpOnly',
+ ], $newResponse->getHeader('Set-Cookie'));
+ }
+
+ public function testNonSecureExpireTokenCookie()
+ {
+ $cookie = new Cookie('foo');
+ $tokenManager = $this->prophesize(TokenManagerInterface::class);
+ $tokenManager->getSignedToken($cookie, 100)->willReturn('bar');
+ $cookieManager = $this->createCookieManager($tokenManager->reveal(), false);
+
+ $originalResponse = new EmptyResponse();
+ $newResponse = $cookieManager->expireCookieByName($originalResponse, 'foo');
+
+ $this->assertSame([
+ 'foo=; Path=/foo; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly',
+ ], $newResponse->getHeader('Set-Cookie'));
+ }
+
+ public function testGetCookieWithExistentToken()
+ {
+ $cookie = new Cookie('foo');
+ $cookie->set('bar', 'baz');
+ $tokenManager = $this->prophesize(TokenManagerInterface::class);
+ $tokenManager->parseSignedToken('bat')->willReturn($cookie);
+ $cookieManager = $this->createCookieManager($tokenManager->reveal());
+
+ $request = $this->prophesize(ServerRequestInterface::class);
+ $request->getHeaderLine('Cookie')->willReturn('foo=bat');
+
+ $this->assertSame($cookie, $cookieManager->getCookie($request->reveal(), 'foo'));
+ }
+
+ public function testGetCookieWithNonExistentToken()
+ {
+ $tokenManager = $this->prophesize(TokenManagerInterface::class);
+ $tokenManager->parseSignedToken('bat')->willReturn(null);
+ $cookieManager = $this->createCookieManager($tokenManager->reveal());
+
+ $request = $this->prophesize(ServerRequestInterface::class);
+ $request->getHeaderLine('Cookie')->willReturn('foo=bat');
+
+ $this->assertSame('foo', $cookieManager->getCookie($request->reveal(), 'foo')->getName());
+ }
+
+ private function createCookieManager(TokenManagerInterface $tokenManager, bool $secure = true) : CookieManager
+ {
+ if (null === $tokenManager) {
+ $tokenManager = $this->prophesize(TokenManagerInterface::class)->reveal();
+ }
+
+ $clock = new FrozenClock(new DateTimeImmutable('@100'));
+
+ return new CookieManager(
+ new CookieSettings('/foo', $secure, 100),
+ $tokenManager,
+ $clock
+ );
+ }
+}
diff --git a/test/Exception/JsonExceptionTest.php b/test/Exception/JsonExceptionTest.php
new file mode 100644
index 0000000..b62bed3
--- /dev/null
+++ b/test/Exception/JsonExceptionTest.php
@@ -0,0 +1,21 @@
+assertSame('Could not decode JSON string: foo', $exception->getMessage());
+ }
+
+ public function testFromJsonEncodeError() : void
+ {
+ $exception = JsonException::fromJsonEncodeError('foo');
+ $this->assertSame('Could not encode data to JSON: foo', $exception->getMessage());
+ }
+}
diff --git a/test/Factory/CookieManagerFactoryTest.php b/test/Factory/CookieManagerFactoryTest.php
new file mode 100644
index 0000000..058f311
--- /dev/null
+++ b/test/Factory/CookieManagerFactoryTest.php
@@ -0,0 +1,73 @@
+prophesize(ContainerInterface::class);
+ $container->get('config')->willReturn([
+ 'pikkuleipa' => [
+ 'default_cookie_settings' => [
+ 'path' => '/',
+ 'secure' => true,
+ 'lifetime' => 100,
+ ],
+ ],
+ ]);
+ $tokenManager = $this->prophesize(TokenManagerInterface::class)->reveal();
+ $container->get(TokenManagerInterface::class)->willReturn($tokenManager);
+
+ $factory = new CookieManagerFactory();
+ $cookieManager = $factory($container->reveal());
+
+ $this->assertAttributeEquals(new CookieSettings('/', true, 100), 'defaultCookieSettings', $cookieManager);
+ $this->assertAttributeSame($tokenManager, 'tokenManager', $cookieManager);
+ }
+
+ public function testSpecificCookieSettings() : void
+ {
+ $container = $this->prophesize(ContainerInterface::class);
+ $container->get('config')->willReturn([
+ 'pikkuleipa' => [
+ 'default_cookie_settings' => [
+ 'path' => '/',
+ 'secure' => true,
+ 'lifetime' => 100,
+ ],
+ 'cookie_settings' => [
+ 'foo' => [
+ 'path' => '/foo',
+ 'secure' => true,
+ 'lifetime' => 100,
+ ],
+ 'bar' => [
+ 'path' => '/bar',
+ 'secure' => false,
+ 'lifetime' => 200,
+ ],
+ ],
+ ],
+ ]);
+ $tokenManager = $this->prophesize(TokenManagerInterface::class)->reveal();
+ $container->get(TokenManagerInterface::class)->willReturn($tokenManager);
+
+ $factory = new CookieManagerFactory();
+ $cookieManager = $factory($container->reveal());
+
+ $this->assertAttributeEquals(new CookieSettings('/', true, 100), 'defaultCookieSettings', $cookieManager);
+ $this->assertAttributeEquals([
+ 'foo' => new CookieSettings('/foo', true, 100),
+ 'bar' => new CookieSettings('/bar', false, 200),
+ ], 'cookieSettings', $cookieManager);
+ $this->assertAttributeSame($tokenManager, 'tokenManager', $cookieManager);
+ }
+}
diff --git a/test/Factory/TokenManagerFactoryTest.php b/test/Factory/TokenManagerFactoryTest.php
new file mode 100644
index 0000000..c2434f9
--- /dev/null
+++ b/test/Factory/TokenManagerFactoryTest.php
@@ -0,0 +1,33 @@
+prophesize(ContainerInterface::class);
+ $container->get('config')->willReturn([
+ 'pikkuleipa' => [
+ 'token' => [
+ 'signer_class' => Sha256::class,
+ 'signature_key' => 'foo',
+ 'verification_key' => 'bar',
+ ],
+ ],
+ ]);
+
+ $factory = new TokenManagerFactory();
+ $tokenManager = $factory($container->reveal());
+
+ $this->assertAttributeInstanceOf(Sha256::class, 'signer', $tokenManager);
+ $this->assertAttributeSame('foo', 'signatureKey', $tokenManager);
+ $this->assertAttributeSame('bar', 'verificationKey', $tokenManager);
+ }
+}
diff --git a/test/TokenManagerTest.php b/test/TokenManagerTest.php
new file mode 100644
index 0000000..f45fd8d
--- /dev/null
+++ b/test/TokenManagerTest.php
@@ -0,0 +1,82 @@
+set('bar', 'baz');
+
+ $clock = new FrozenClock(new DateTimeImmutable('@100'));
+ $tokenManager = new TokenManager(new Sha256(), 'foo', 'foo', new Parser(), $clock);
+ $token = $tokenManager->getSignedToken($cookie, 100);
+
+ $data = json_decode(base64_decode(explode('.', $token)[1]), true);
+ $this->assertSame('foo', $data['sub']);
+ $this->assertSame(100, $data['iat']);
+ $this->assertSame(200, $data['exp']);
+ $this->assertSame(['bar' => 'baz'], json_decode($data['dat'], true));
+ }
+
+ public function testGetSignedTokenWithoutExpire()
+ {
+ $cookie = new Cookie('foo');
+ $cookie->set('bar', 'baz');
+
+ $clock = new FrozenClock(new DateTimeImmutable('@100'));
+ $tokenManager = new TokenManager(new Sha256(), 'foo', 'foo', new Parser(), $clock);
+ $token = $tokenManager->getSignedToken($cookie);
+
+ $data = json_decode(base64_decode(explode('.', $token)[1]), true);
+ $this->assertArrayNotHasKey('exp', $data);
+ }
+
+ public function testParseProperlySignedToken()
+ {
+ $cookie = new Cookie('foo');
+ $cookie->set('bar', 'baz');
+
+ $clock = new FrozenClock(new DateTimeImmutable('@100'));
+ $tokenManager = new TokenManager(new Sha256(), 'foo', 'foo', new Parser(), $clock);
+ $cookie = $tokenManager->parseSignedToken((string) $tokenManager->getSignedToken($cookie, 100));
+
+ $this->assertEquals(new DateTimeImmutable('@100'), $cookie->getIssuedAt());
+ $this->assertSame('baz', $cookie->get('bar'));
+ $this->assertSame('foo', $cookie->getName());
+ }
+
+ public function testParseMalformedToken()
+ {
+ $tokenManager = new TokenManager(new Sha256(), 'foo', 'foo');
+ $this->assertNull($tokenManager->parseSignedToken('foo'));
+ }
+
+ public function testParseExpiredToken()
+ {
+ $clock = new FrozenClock(new DateTimeImmutable('@100'));
+ $tokenManager = new TokenManager(new Sha256(), 'foo', 'foo', new Parser(), $clock);
+ $this->assertNull(
+ $tokenManager->parseSignedToken((string) $tokenManager->getSignedToken(new Cookie('foo'), -1))
+ );
+ }
+
+ public function testIllegalToken()
+ {
+ $clock = new FrozenClock(new DateTimeImmutable('@100'));
+ $tokenManager = new TokenManager(new Sha256(), 'foo', 'bar', new Parser(), $clock);
+ $this->assertNull(
+ $tokenManager->parseSignedToken((string) $tokenManager->getSignedToken(new Cookie('foo'), 1))
+ );
+ }
+}