diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..27b765f
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+/tests export-ignore
+/.github export-ignore
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..0f7d23f
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,25 @@
+on:
+ push:
+ # Sequence of patterns matched against refs/tags
+ tags:
+ - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
+
+name: Release
+
+jobs:
+ release:
+ name: Release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Create Release
+ id: create_release
+ uses: actions/create-release@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ tag_name: ${{ github.ref }}
+ release_name: Release ${{ github.ref }}
+ draft: false
+ prerelease: false
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0250909
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+/vendor/
+composer.lock
+*.cache
+*.log
+.idea/
+.DS_Store
diff --git a/.php_cs b/.php_cs
new file mode 100755
index 0000000..bf0a627
--- /dev/null
+++ b/.php_cs
@@ -0,0 +1,91 @@
+setRiskyAllowed(true)
+ ->setRules([
+ '@PSR2' => true,
+ '@Symfony' => true,
+ '@DoctrineAnnotation' => true,
+ '@PhpCsFixer' => true,
+ 'header_comment' => [
+ 'commentType' => 'PHPDoc',
+ 'header' => $header,
+ 'separate' => 'none',
+ 'location' => 'after_declare_strict',
+ ],
+ 'array_syntax' => [
+ 'syntax' => 'short'
+ ],
+ 'list_syntax' => [
+ 'syntax' => 'short'
+ ],
+ 'concat_space' => [
+ 'spacing' => 'one'
+ ],
+ 'blank_line_before_statement' => [
+ 'statements' => [
+ 'declare',
+ ],
+ ],
+ 'general_phpdoc_annotation_remove' => [
+ 'annotations' => [
+ 'author'
+ ],
+ ],
+ 'ordered_imports' => [
+ 'imports_order' => [
+ 'class', 'function', 'const',
+ ],
+ 'sort_algorithm' => 'alpha',
+ ],
+ 'single_line_comment_style' => [
+ 'comment_types' => [
+ ],
+ ],
+ 'yoda_style' => [
+ 'always_move_variable' => false,
+ 'equal' => false,
+ 'identical' => false,
+ ],
+ 'phpdoc_align' => [
+ 'align' => 'left',
+ ],
+ 'multiline_whitespace_before_semicolons' => [
+ 'strategy' => 'no_multi_line',
+ ],
+ 'constant_case' => [
+ 'case' => 'lower',
+ ],
+ 'class_attributes_separation' => true,
+ 'combine_consecutive_unsets' => true,
+ 'declare_strict_types' => true,
+ 'linebreak_after_opening_tag' => true,
+ 'lowercase_static_reference' => true,
+ 'no_useless_else' => true,
+ 'no_unused_imports' => true,
+ 'not_operator_with_successor_space' => true,
+ 'not_operator_with_space' => false,
+ 'ordered_class_elements' => true,
+ 'php_unit_strict' => false,
+ 'phpdoc_separation' => false,
+ 'single_quote' => true,
+ 'standardize_not_equals' => true,
+ 'multiline_comment_opening_closing' => true,
+ ])
+ ->setFinder(
+ PhpCsFixer\Finder::create()
+ ->exclude('bin')
+ ->exclude('public')
+ ->exclude('runtime')
+ ->exclude('vendor')
+ ->in(__DIR__)
+ )
+ ->setUsingCache(false);
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..c1ce37a
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,38 @@
+language: php
+
+sudo: required
+
+matrix:
+ include:
+ - php: 7.2
+ env: SW_VERSION="4.5.3RC1"
+ - php: 7.3
+ env: SW_VERSION="4.5.3RC1"
+ - php: 7.4
+ env: SW_VERSION="4.5.3RC1"
+
+ allow_failures:
+ - php: master
+
+services:
+ - docker
+
+before_install:
+ - export PHP_MAJOR="$(`phpenv which php` -r 'echo phpversion();' | cut -d '.' -f 1)"
+ - export PHP_MINOR="$(`phpenv which php` -r 'echo phpversion();' | cut -d '.' -f 2)"
+ - echo $PHP_MAJOR
+ - echo $PHP_MINOR
+
+install:
+ - cd $TRAVIS_BUILD_DIR
+ - bash .travis/swoole.install.sh
+ - phpenv config-rm xdebug.ini || echo "xdebug not available"
+ - phpenv config-add .travis/ci.ini
+
+before_script:
+ - cd $TRAVIS_BUILD_DIR
+ - composer config -g process-timeout 900 && composer update
+
+script:
+ - composer analyse
+ - composer test
\ No newline at end of file
diff --git a/.travis/ci.ini b/.travis/ci.ini
new file mode 100644
index 0000000..101b1e3
--- /dev/null
+++ b/.travis/ci.ini
@@ -0,0 +1,5 @@
+[opcache]
+opcache.enable_cli=1
+
+[swoole]
+extension = "swoole.so"
diff --git a/.travis/swoole.install.sh b/.travis/swoole.install.sh
new file mode 100644
index 0000000..0067690
--- /dev/null
+++ b/.travis/swoole.install.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+wget https://github.com/swoole/swoole-src/archive/v"${SW_VERSION}".tar.gz -O swoole.tar.gz
+mkdir -p swoole
+tar -xf swoole.tar.gz -C swoole --strip-components=1
+rm swoole.tar.gz
+cd swoole || exit
+phpize
+./configure --enable-openssl --enable-mysqlnd --enable-http2
+make -j "$(nproc)"
+make install
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..b716978
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) Sean Tymon
+Copyright (c) Eric Zhu
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b042d4e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,294 @@
+# Hyperf JWT 组件
+
+该组件基于 [`tymon/jwt-auth`](https://github.com/tymondesigns/jwt-auth ),实现了完整用于 JWT 认证的能力。
+
+该组件并不直接提供身份认证的能力,你可以基于该组件提供的功能特性来实现自己的身份认证。
+
+如果你不想自己动手,可以同时安装 [`hyperf-ext/auth`](https://github.com/hyperf-ext/auth) 组件来获得接近开箱即用的身份认证和授权功能。
+
+## 安装
+
+```shell script
+composer require hyperf-ext/jwt
+```
+
+## 发布配置
+
+```shell script
+php bin/hyperf.php vendor:publish hyperf-ext/jwt
+```
+
+> 文件位于 `config/autoload/jwt.php`。
+
+## 配置
+
+```php
+[
+ /*
+ |--------------------------------------------------------------------------
+ | JWT 密钥
+ |--------------------------------------------------------------------------
+ |
+ | 该密钥用于签名你的令牌,切记要在 .env 文件中设置。组件提供了一个辅助命令来完成
+ | 这步操作:
+ | `php bin/hyperf.php gen:jwt-secret`
+ |
+ | 注意:该密钥仅用于对称算法(HMAC),RSA 和 ECDSA 使用公私钥体系(见下方)。
+ |
+ | 注意:该值必须使用 BASE64 编码。
+ |
+ */
+
+ 'secret' => env('JWT_SECRET'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | JWT 公私钥
+ |--------------------------------------------------------------------------
+ |
+ | 你使用的算法将决定你的令牌是使用随机字符串(在 `JWT_SECRET` 中定设置)还是
+ | 使用以下公钥和私钥来签名。组件提供了一个辅助命令来完成这步操作:
+ | `php bin/hyperf.php gen:jwt-keypair`
+ |
+ | 对称算法:
+ | HS256、HS384 和 HS512 使用 `JWT_SECRET`。
+ |
+ | 非对称算法:
+ | RS256、RS384 和 RS512 / ES256、ES384 和 ES512 使用下面的公私钥。
+ |
+ */
+
+ 'keys' => [
+ /*
+ |--------------------------------------------------------------------------
+ | 公钥
+ |--------------------------------------------------------------------------
+ |
+ | 你的公钥内容。
+ |
+ */
+
+ 'public' => env('JWT_PUBLIC_KEY'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | 私钥
+ |--------------------------------------------------------------------------
+ |
+ | 你的私钥内容。
+ |
+ */
+
+ 'private' => env('JWT_PRIVATE_KEY'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | 密码
+ |--------------------------------------------------------------------------
+ |
+ | 你的私钥的密码。不需要密码可设置为 `null`。
+ |
+ | 注意:该值必须使用 BASE64 编码。
+ |
+ */
+
+ 'passphrase' => env('JWT_PASSPHRASE'),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | JWT 生存时间
+ |--------------------------------------------------------------------------
+ |
+ | 指定令牌有效的时长(以秒为单位)。默认为 1 小时。
+ |
+ | 你可以将其设置为 `null`,以产生永不过期的令牌。某些场景下有人可能想要这种行为,
+ | 例如在用于手机应用的情况下。
+ | 不太推荐这样做,因此请确保你有适当的体系来在必要时可以撤消令牌。
+ | 注意:如果将其设置为 `null`,则应从 `required_claims` 列表中删除 `exp` 元素。
+ |
+ */
+
+ 'ttl' => env('JWT_TTL', 3600),
+
+ /*
+ |--------------------------------------------------------------------------
+ | 刷新生存时间
+ |--------------------------------------------------------------------------
+ |
+ | 指定一个时长以在其有效期内可刷新令牌(以秒为单位)。 例如,用户可以
+ | 在创建原始令牌后的 2 周内刷新该令牌,直到他们必须重新进行身份验证为止。
+ | 默认为 2 周。
+ |
+ | 你可以将其设置为 `null`,以提供无限的刷新时间。某些场景下有人可能想要这种行为,
+ | 而不是永不过期的令牌,例如在用于手机应用的情况下。
+ | 不太推荐这样做,因此请确保你有适当的体系来在必要时可以撤消令牌。
+ |
+ */
+
+ 'refresh_ttl' => env('JWT_REFRESH_TTL', 3600 * 24 * 14),
+
+ /*
+ |--------------------------------------------------------------------------
+ | JWT 哈希算法
+ |--------------------------------------------------------------------------
+ |
+ | 用于签名你的令牌的哈希算法。
+ |
+ | 关于算法的详细描述可参阅 https://tools.ietf.org/html/rfc7518。
+ |
+ | 可能的值:HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512
+ |
+ */
+
+ 'algo' => env('JWT_ALGO', 'HS512'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | 必要声明
+ |--------------------------------------------------------------------------
+ |
+ | 指定在任一令牌中必须存在的必要声明。如果在有效载荷中不存在这些声明中的任意一个,
+ | 则将抛出 `TokenInvalidException` 异常。
+ |
+ */
+
+ 'required_claims' => [
+ 'iss',
+ 'iat',
+ 'exp',
+ 'nbf',
+ 'sub',
+ 'jti',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | 保留声明
+ |--------------------------------------------------------------------------
+ |
+ | 指定在刷新令牌时要保留的声明的键名。
+ | 除了这些声明之外,`sub`、`iat` 和 `prv`(如果有)声明也将自动保留。
+ |
+ | 注意:如果有声明不存在,则会将其忽略。
+ |
+ */
+
+ 'persistent_claims' => [
+ // 'foo',
+ // 'bar',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | 锁定主题声明
+ |--------------------------------------------------------------------------
+ |
+ | 这将决定是否将一个 `prv` 声明自动添加到令牌中。
+ | 此目的是确保在你拥有多个身份验证模型时,例如 `App\User` 和 `App\OtherPerson`,
+ | 如果两个令牌在两个不同的模型中碰巧具有相同的 ID(`sub` 声明),则我们应当防止
+ | 一个身份验证请求冒充另一个身份验证请求。
+ |
+ | 在特定情况下,你可能需要禁用该行为,例如你只有一个身份验证模型的情况下,
+ | 这可以减少一些令牌大小。
+ |
+ */
+
+ 'lock_subject' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | 时间容差
+ |--------------------------------------------------------------------------
+ |
+ | 该属性为 JWT 的时间戳类声明提供了一些时间上的容差。
+ | 这意味着,如果你的某些服务器上不可避免地存在轻微的时钟偏差,
+ | 那么这将可以为此提供一定程度的缓冲。
+ |
+ | 该设置适用于 `iat`、`nbf` 和 `exp`声明。
+ | 以秒为单位设置该值,仅在你了解你真正需要它时才指定。
+ |
+ */
+
+ 'leeway' => env('JWT_LEEWAY', 0),
+
+ /*
+ |--------------------------------------------------------------------------
+ | 启用黑名单
+ |--------------------------------------------------------------------------
+ |
+ | 为使令牌无效,你必须启用黑名单。
+ | 如果你不想或不需要此功能,请将其设置为 `false`。
+ |
+ */
+
+ 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),
+
+ /*
+ | -------------------------------------------------------------------------
+ | 黑名单宽限期
+ | -------------------------------------------------------------------------
+ |
+ | 当使用同一个 JWT 发送多个并发请求时,由于每次请求都会重新生成令牌,
+ | 因此其中一些可能会失败。
+ |
+ | 设置宽限期(以秒为单位)以防止并发请求失败。
+ |
+ */
+
+ 'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 0),
+
+ /*
+ |--------------------------------------------------------------------------
+ | 黑名单存储
+ |--------------------------------------------------------------------------
+ |
+ | 指定用于实现在黑名单中存储令牌行为的类。
+ |
+ | 自定义存储类需要实现 `HyperfExt\Jwt\Contracts\StorageInterface` 接口。
+ |
+ */
+
+ 'blacklist_storage' => HyperfExt\Jwt\Storage\HyperfCache::class,
+];
+```
+
+## 使用
+
+如果你使用 [`hyperf-ext/auth`](https://github.com/hyperf-ext/auth) 组件,则可以忽略该部分。
+
+```php
+manager = $manager;
+ $this->jwt = $jwtFactory->make();
+ }
+}
+```
+
+可阅读上述两个类来详细了解如何使用。
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..e572cff
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,67 @@
+{
+ "name": "hyperf-ext/jwt",
+ "type": "library",
+ "license": "MIT",
+ "keywords": [
+ "php",
+ "hyperf",
+ "auth",
+ "jwt"
+ ],
+ "description": "The Hyperf JWT package.",
+ "authors": [
+ {
+ "name": "Eric Zhu",
+ "email": "eric@zhu.email"
+ },
+ {
+ "name": "Sean Tymon",
+ "email": "tymon148@gmail.com",
+ "homepage": "https://tymon.xyz",
+ "role": "Developer"
+ }
+ ],
+ "autoload": {
+ "psr-4": {
+ "HyperfExt\\Jwt\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "HyperfTest\\": "tests"
+ }
+ },
+ "require": {
+ "php": ">=7.2",
+ "ext-swoole": ">=4.5",
+ "ext-json": "*",
+ "ext-openssl": "*",
+ "hyperf/cache": "^2.0",
+ "hyperf/command": "^2.0",
+ "hyperf/config": "^2.0",
+ "hyperf/di": "^2.0",
+ "hyperf/framework": "^2.0",
+ "lcobucci/jwt": "^3.2",
+ "nesbot/carbon": "^2.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^2.14",
+ "hyperf/testing": "^2.0",
+ "phpstan/phpstan": "^0.12",
+ "swoole/ide-helper": "dev-master",
+ "mockery/mockery": "^1.0"
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "scripts": {
+ "test": "co-phpunit -c phpunit.xml --colors=always",
+ "analyse": "phpstan analyse --memory-limit 1024M -l 0 ./src",
+ "cs-fix": "php-cs-fixer fix $1"
+ },
+ "extra": {
+ "hyperf": {
+ "config": "HyperfExt\\Jwt\\ConfigProvider"
+ }
+ }
+}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..d2c615a
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,15 @@
+
+
+
+ ./tests/
+
+
\ No newline at end of file
diff --git a/publish/jwt.php b/publish/jwt.php
new file mode 100644
index 0000000..be03232
--- /dev/null
+++ b/publish/jwt.php
@@ -0,0 +1,246 @@
+ env('JWT_SECRET'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | JWT Authentication Keys
+ |--------------------------------------------------------------------------
+ |
+ | The algorithm you are using, will determine whether your tokens are
+ | signed with a random string (defined in `JWT_SECRET`) or using the
+ | following public and private keys. A helper command is provided for this:
+ | `php bin/hyperf.php gen:jwt-keypair`
+ |
+ | Symmetric Algorithms:
+ | HS256, HS384 & HS512 will use `JWT_SECRET`.
+ |
+ | Asymmetric Algorithms:
+ | RS256, RS384 & RS512 / ES256, ES384 & ES512 will use the keys below.
+ |
+ */
+
+ 'keys' => [
+ /*
+ |--------------------------------------------------------------------------
+ | Public Key
+ |--------------------------------------------------------------------------
+ |
+ | Your public key content.
+ |
+ */
+
+ 'public' => env('JWT_PUBLIC_KEY'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Private Key
+ |--------------------------------------------------------------------------
+ |
+ | Your private key content.
+ |
+ */
+
+ 'private' => env('JWT_PRIVATE_KEY'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Passphrase
+ |--------------------------------------------------------------------------
+ |
+ | The passphrase for your private key. Can be null if none set.
+ |
+ | Note: This value must be encoded by base64.
+ |
+ */
+
+ 'passphrase' => env('JWT_PASSPHRASE'),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | JWT time to live
+ |--------------------------------------------------------------------------
+ |
+ | Specify the length of time (in seconds) that the token will be valid for.
+ | Defaults to 1 hour.
+ |
+ | You can also set this to null, to yield a never expiring token.
+ | Some people may want this behaviour for e.g. a mobile app.
+ | This is not particularly recommended, so make sure you have appropriate
+ | systems in place to revoke the token if necessary.
+ | Notice: If you set this to null you should remove 'exp' element from 'required_claims' list.
+ |
+ */
+
+ 'ttl' => env('JWT_TTL', 3600),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Refresh time to live
+ |--------------------------------------------------------------------------
+ |
+ | Specify the length of time (in seconds) that the token can be refreshed
+ | within. I.E. The user can refresh their token within a 2 week window of
+ | the original token being created until they must re-authenticate.
+ | Defaults to 2 weeks.
+ |
+ | You can also set this to null, to yield an infinite refresh time.
+ | Some may want this instead of never expiring tokens for e.g. a mobile app.
+ | This is not particularly recommended, so make sure you have appropriate
+ | systems in place to revoke the token if necessary.
+ |
+ */
+
+ 'refresh_ttl' => env('JWT_REFRESH_TTL', 3600 * 24 * 14),
+
+ /*
+ |--------------------------------------------------------------------------
+ | JWT hashing algorithm
+ |--------------------------------------------------------------------------
+ |
+ | Specify the hashing algorithm that will be used to sign the token.
+ |
+ | possible values: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512
+ |
+ */
+
+ 'algo' => env('JWT_ALGO', 'HS512'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Required Claims
+ |--------------------------------------------------------------------------
+ |
+ | Specify the required claims that must exist in any token.
+ | A TokenInvalidException will be thrown if any of these claims are not
+ | present in the payload.
+ |
+ */
+
+ 'required_claims' => [
+ 'iss',
+ 'iat',
+ 'exp',
+ 'nbf',
+ 'sub',
+ 'jti',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Persistent Claims
+ |--------------------------------------------------------------------------
+ |
+ | Specify the claim keys to be persisted when refreshing a token.
+ | `sub` and `iat` will automatically be persisted, in
+ | addition to the these claims.
+ |
+ | Note: If a claim does not exist then it will be ignored.
+ |
+ */
+
+ 'persistent_claims' => [
+ // 'foo',
+ // 'bar',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Lock Subject
+ |--------------------------------------------------------------------------
+ |
+ | This will determine whether a `prv` claim is automatically added to
+ | the token. The purpose of this is to ensure that if you have multiple
+ | authentication models e.g. `App\User` & `App\OtherPerson`, then we
+ | should prevent one authentication request from impersonating another,
+ | if 2 tokens happen to have the same id across the 2 different models.
+ |
+ | Under specific circumstances, you may want to disable this behaviour
+ | e.g. if you only have one authentication model, then you would save
+ | a little on token size.
+ |
+ */
+
+ 'lock_subject' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Leeway
+ |--------------------------------------------------------------------------
+ |
+ | This property gives the jwt timestamp claims some "leeway".
+ | Meaning that if you have any unavoidable slight clock skew on
+ | any of your servers then this will afford you some level of cushioning.
+ |
+ | This applies to the claims `iat`, `nbf` and `exp`.
+ |
+ | Specify in seconds - only if you know you need it.
+ |
+ */
+
+ 'leeway' => env('JWT_LEEWAY', 0),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Blacklist Enabled
+ |--------------------------------------------------------------------------
+ |
+ | In order to invalidate tokens, you must have the blacklist enabled.
+ | If you do not want or need this functionality, then set this to false.
+ |
+ */
+
+ 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),
+
+ /*
+ | -------------------------------------------------------------------------
+ | Blacklist Grace Period
+ | -------------------------------------------------------------------------
+ |
+ | When multiple concurrent requests are made with the same JWT,
+ | it is possible that some of them fail, due to token regeneration
+ | on every request.
+ |
+ | Set grace period in seconds to prevent parallel request failure.
+ |
+ */
+
+ 'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 0),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Blacklist Storage
+ |--------------------------------------------------------------------------
+ |
+ | Specify the handler that is used to store tokens in the blacklist.
+ |
+ */
+
+ 'blacklist_storage' => HyperfExt\Jwt\Storage\HyperfCache::class,
+];
diff --git a/src/Blacklist.php b/src/Blacklist.php
new file mode 100644
index 0000000..1c76da9
--- /dev/null
+++ b/src/Blacklist.php
@@ -0,0 +1,208 @@
+storage = $storage;
+ $this->gracePeriod = $gracePeriod;
+ $this->refreshTtl = $refreshTtl;
+ }
+
+ /**
+ * Add the token (jti claim) to the blacklist.
+ */
+ public function add(Payload $payload): bool
+ {
+ // if there is no exp claim then add the jwt to
+ // the blacklist indefinitely
+ if (! $payload->hasKey('exp')) {
+ return $this->addForever($payload);
+ }
+
+ // if we have already added this token to the blacklist
+ if (! empty($this->storage->get($this->getKey($payload)))) {
+ return true;
+ }
+
+ $this->storage->add(
+ $this->getKey($payload),
+ ['valid_until' => $this->getGraceTimestamp()],
+ $this->getSecondsUntilExpired($payload)
+ );
+
+ return true;
+ }
+
+ /**
+ * Add the token (jti claim) to the blacklist indefinitely.
+ */
+ public function addForever(Payload $payload): bool
+ {
+ $this->storage->forever($this->getKey($payload), 'forever');
+
+ return true;
+ }
+
+ /**
+ * Determine whether the token has been blacklisted.
+ */
+ public function has(Payload $payload): bool
+ {
+ $val = $this->storage->get((string) $this->getKey($payload));
+
+ // exit early if the token was blacklisted forever,
+ if ($val === 'forever') {
+ return true;
+ }
+
+ // check whether the expiry + grace has past
+ return ! empty($val) and ! Utils::isFuture($val['valid_until']);
+ }
+
+ /**
+ * Remove the token (jti claim) from the blacklist.
+ */
+ public function remove(Payload $payload): bool
+ {
+ return $this->storage->destroy($this->getKey($payload));
+ }
+
+ /**
+ * Remove all tokens from the blacklist.
+ */
+ public function clear(): bool
+ {
+ $this->storage->flush();
+
+ return true;
+ }
+
+ /**
+ * Set the grace period.
+ *
+ * @return $this
+ */
+ public function setGracePeriod(int $gracePeriod)
+ {
+ $this->gracePeriod = (int) $gracePeriod;
+
+ return $this;
+ }
+
+ /**
+ * Get the grace period.
+ */
+ public function getGracePeriod(): int
+ {
+ return $this->gracePeriod;
+ }
+
+ /**
+ * Get the unique key held within the blacklist.
+ *
+ * @return mixed
+ */
+ public function getKey(Payload $payload)
+ {
+ return $payload($this->key);
+ }
+
+ /**
+ * Set the unique key held within the blacklist.
+ *
+ * @return $this
+ */
+ public function setKey(string $key)
+ {
+ $this->key = value($key);
+
+ return $this;
+ }
+
+ /**
+ * Set the refresh time limit.
+ *
+ * @return $this
+ */
+ public function setRefreshTtl(?int $refreshTtl)
+ {
+ $this->refreshTtl = $refreshTtl === null ? null : (int) $refreshTtl;
+
+ return $this;
+ }
+
+ /**
+ * Get the refresh time limit.
+ */
+ public function getRefreshTtl(): ?int
+ {
+ return $this->refreshTtl;
+ }
+
+ /**
+ * Get the number of seconds until the token expiry.
+ */
+ protected function getSecondsUntilExpired(Payload $payload): int
+ {
+ $exp = Utils::timestamp($payload['exp']);
+ $iat = Utils::timestamp($payload['iat']);
+
+ // get the latter of the two expiration dates and find
+ // the number of seconds until the expiration date,
+ // plus 1 minute to avoid overlap
+ return $exp->max($iat->addSeconds($this->refreshTtl))->addMinute()->diffInRealSeconds();
+ }
+
+ /**
+ * Get the timestamp when the blacklist comes into effect
+ * This defaults to immediate (0 seconds).
+ */
+ protected function getGraceTimestamp(): int
+ {
+ return Utils::now()->addSeconds($this->gracePeriod)->getTimestamp();
+ }
+}
diff --git a/src/Claims/AbstractClaim.php b/src/Claims/AbstractClaim.php
new file mode 100644
index 0000000..a5eea70
--- /dev/null
+++ b/src/Claims/AbstractClaim.php
@@ -0,0 +1,152 @@
+setValue($value);
+ }
+
+ /**
+ * Get the payload as a string.
+ */
+ public function __toString(): string
+ {
+ return $this->toJson();
+ }
+
+ /**
+ * Set the claim value, and call a validate method.
+ *
+ * @param mixed $value
+ *
+ * @return $this
+ */
+ public function setValue($value)
+ {
+ $this->value = $this->validateCreate($value);
+
+ return $this;
+ }
+
+ /**
+ * Get the claim value.
+ *
+ * @return mixed
+ */
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ /**
+ * Set the claim name.
+ *
+ * @return $this
+ */
+ public function setName(string $name)
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ /**
+ * Get the claim name.
+ */
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ /**
+ * Validate the claim in a standalone Claim context.
+ *
+ * @param mixed $value
+ */
+ public function validateCreate($value)
+ {
+ return $value;
+ }
+
+ /**
+ * Checks if the value matches the claim.
+ *
+ * @param mixed $value
+ */
+ public function matches($value, bool $strict = true): bool
+ {
+ return $strict ? $this->value === $value : $this->value == $value;
+ }
+
+ /**
+ * Convert the object into something JSON serializable.
+ */
+ public function jsonSerialize(): array
+ {
+ return $this->toArray();
+ }
+
+ /**
+ * Build a key value array comprising of the claim name and value.
+ */
+ public function toArray(): array
+ {
+ return [$this->getName() => $this->getValue()];
+ }
+
+ /**
+ * Get the claim as JSON.
+ */
+ public function toJson(int $options = JSON_UNESCAPED_SLASHES): string
+ {
+ return json_encode($this->toArray(), $options);
+ }
+
+ protected function getFactory(): Factory
+ {
+ if (! empty($this->factory)) {
+ return $this->factory;
+ }
+ return $this->factory = ApplicationContext::getContainer()->get(ManagerInterface::class)->getClaimFactory();
+ }
+}
diff --git a/src/Claims/Audience.php b/src/Claims/Audience.php
new file mode 100644
index 0000000..f13eac7
--- /dev/null
+++ b/src/Claims/Audience.php
@@ -0,0 +1,21 @@
+getArrayableItems($items));
+ }
+
+ /**
+ * Get a Claim instance by it's unique name.
+ *
+ * @param mixed $default
+ */
+ public function getByClaimName(string $name, ?callable $callback = null, $default = null): AbstractClaim
+ {
+ return $this->filter(function (AbstractClaim $claim) use ($name) {
+ return $claim->getName() === $name;
+ })->first($callback, $default);
+ }
+
+ /**
+ * Validate each claim.
+ *
+ * @return $this
+ */
+ public function validate(bool $ignoreExpired = false)
+ {
+ $this->each(function ($claim) use ($ignoreExpired) {
+ $claim->validate($ignoreExpired);
+ });
+ return $this;
+ }
+
+ /**
+ * Determine if the Collection contains all of the given keys.
+ *
+ * @param mixed $claims
+ */
+ public function hasAllClaims($claims): bool
+ {
+ return count($claims) and (new static($claims))->diff($this->keys())->isEmpty();
+ }
+
+ /**
+ * Get the claims as key/val array.
+ */
+ public function toPlainArray(): array
+ {
+ return $this->map(function (AbstractClaim $claim) {
+ return $claim->getValue();
+ })->toArray();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getArrayableItems($items): array
+ {
+ return $this->sanitizeClaims($items);
+ }
+
+ /**
+ * Ensure that the given claims array is keyed by the claim name.
+ *
+ * @param mixed $items
+ */
+ private function sanitizeClaims($items): array
+ {
+ $claims = [];
+ foreach ($items as $key => $value) {
+ if (! is_string($key) and $value instanceof AbstractClaim) {
+ $key = $value->getName();
+ }
+
+ $claims[$key] = $value;
+ }
+
+ return $claims;
+ }
+}
diff --git a/src/Claims/Custom.php b/src/Claims/Custom.php
new file mode 100644
index 0000000..4d2ff4f
--- /dev/null
+++ b/src/Claims/Custom.php
@@ -0,0 +1,28 @@
+setName($name);
+ }
+
+ public function validate(bool $ignoreExpired = false): bool
+ {
+ return true;
+ }
+}
diff --git a/src/Claims/DatetimeTrait.php b/src/Claims/DatetimeTrait.php
new file mode 100644
index 0000000..9877b99
--- /dev/null
+++ b/src/Claims/DatetimeTrait.php
@@ -0,0 +1,92 @@
+add($value);
+ }
+
+ if ($value instanceof DateTimeInterface) {
+ $value = $value->getTimestamp();
+ }
+
+ return parent::setValue($value);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateCreate($value)
+ {
+ if (! is_numeric($value)) {
+ throw new InvalidClaimException($this);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Set the leeway in seconds.
+ *
+ * @return $this
+ */
+ public function setLeeway(int $leeway)
+ {
+ $this->leeway = $leeway;
+
+ return $this;
+ }
+
+ /**
+ * Determine whether the value is in the future.
+ *
+ * @param mixed $value
+ */
+ protected function isFuture($value): bool
+ {
+ return Utils::isFuture((int) $value, (int) $this->leeway);
+ }
+
+ /**
+ * Determine whether the value is in the past.
+ *
+ * @param mixed $value
+ */
+ protected function isPast($value): bool
+ {
+ return Utils::isPast((int) $value, (int) $this->leeway);
+ }
+}
diff --git a/src/Claims/Expiration.php b/src/Claims/Expiration.php
new file mode 100644
index 0000000..c0e7f3a
--- /dev/null
+++ b/src/Claims/Expiration.php
@@ -0,0 +1,28 @@
+isPast($this->getValue())) {
+ throw new TokenExpiredException('Token has expired');
+ }
+ return true;
+ }
+}
diff --git a/src/Claims/Factory.php b/src/Claims/Factory.php
new file mode 100644
index 0000000..1ad32bc
--- /dev/null
+++ b/src/Claims/Factory.php
@@ -0,0 +1,185 @@
+ Audience::class,
+ 'exp' => Expiration::class,
+ 'iat' => IssuedAt::class,
+ 'iss' => Issuer::class,
+ 'jti' => JwtId::class,
+ 'nbf' => NotBefore::class,
+ 'sub' => Subject::class,
+ ];
+
+ public function __construct(?int $ttl, ?int $refreshTtl, int $leeway = 0)
+ {
+ $this->setTtl($ttl);
+ $this->setRefreshTtl($refreshTtl);
+ $this->setLeeway($leeway);
+ }
+
+ /**
+ * Get the instance of the claim when passing the name and value.
+ *
+ * @param mixed $value
+ */
+ public function get(string $name, $value): ClaimInterface
+ {
+ if ($this->has($name)) {
+ $claim = make($this->classMap[$name], ['factory' => $this, 'value' => $value]);
+
+ return method_exists($claim, 'setLeeway') ?
+ $claim->setLeeway($this->leeway) :
+ $claim;
+ }
+
+ return new Custom($name, $value);
+ }
+
+ /**
+ * Check whether the claim exists.
+ */
+ public function has(string $name): bool
+ {
+ return array_key_exists($name, $this->classMap);
+ }
+
+ /**
+ * Generate the initial value and return the Claim instance.
+ */
+ public function make(string $name): ClaimInterface
+ {
+ return $this->get($name, $this->{$name}());
+ }
+
+ /**
+ * Add a new claim mapping.
+ *
+ * @return $this
+ */
+ public function extend(string $name, string $classPath)
+ {
+ $this->classMap[$name] = $classPath;
+
+ return $this;
+ }
+
+ /**
+ * Set the token ttl (in seconds).
+ *
+ * @return $this
+ */
+ public function setTtl(?int $ttl)
+ {
+ $this->ttl = $ttl === null ? null : (int) $ttl;
+
+ return $this;
+ }
+
+ /**
+ * Get the token ttl.
+ */
+ public function getTtl(): ?int
+ {
+ return $this->ttl;
+ }
+
+ /**
+ * Set the token refresh ttl (in seconds).
+ *
+ * @return $this
+ */
+ public function setRefreshTtl(?int $refreshTtl)
+ {
+ $this->refreshTtl = $refreshTtl === null ? null : (int) $refreshTtl;
+
+ return $this;
+ }
+
+ /**
+ * Get the token refresh ttl.
+ */
+ public function getRefreshTtl(): ?int
+ {
+ return $this->refreshTtl;
+ }
+
+ /**
+ * Set the leeway in seconds.
+ *
+ * @return $this
+ */
+ public function setLeeway(int $leeway)
+ {
+ $this->leeway = $leeway;
+
+ return $this;
+ }
+
+ public function iss(): string
+ {
+ return ApplicationContext::getContainer()->get(ServerRequestInterface::class)->url();
+ }
+
+ public function iat(): int
+ {
+ return time();
+ }
+
+ public function exp(): int
+ {
+ return time() + $this->getTtl();
+ }
+
+ public function nbf(): int
+ {
+ return time();
+ }
+
+ public function jti(): string
+ {
+ return Str::random(16);
+ }
+}
diff --git a/src/Claims/IssuedAt.php b/src/Claims/IssuedAt.php
new file mode 100644
index 0000000..6f6cd04
--- /dev/null
+++ b/src/Claims/IssuedAt.php
@@ -0,0 +1,51 @@
+commonValidateCreate($value);
+
+ if ($this->isFuture($value)) {
+ throw new InvalidClaimException($this);
+ }
+
+ return $value;
+ }
+
+ public function validate(bool $ignoreExpired = false): bool
+ {
+ if ($this->isFuture($value = $this->getValue())) {
+ throw new TokenInvalidException('Issued At (iat) timestamp cannot be in the future');
+ }
+
+ if (
+ ($refreshTtl = $this->getFactory()->getRefreshTtl()) !== null and
+ $this->isPast($value + $refreshTtl)
+ ) {
+ throw new TokenExpiredException('Token has expired and can no longer be refreshed');
+ }
+
+ return true;
+ }
+}
diff --git a/src/Claims/Issuer.php b/src/Claims/Issuer.php
new file mode 100644
index 0000000..65acc86
--- /dev/null
+++ b/src/Claims/Issuer.php
@@ -0,0 +1,21 @@
+isFuture($this->getValue())) {
+ throw new TokenInvalidException('Not Before (nbf) timestamp cannot be in the future');
+ }
+ return true;
+ }
+}
diff --git a/src/Claims/Subject.php b/src/Claims/Subject.php
new file mode 100644
index 0000000..f25d046
--- /dev/null
+++ b/src/Claims/Subject.php
@@ -0,0 +1,21 @@
+ HS256::class,
+ 'HS384' => HS384::class,
+ 'HS512' => HS512::class,
+ 'RS256' => RS256::class,
+ 'RS384' => RS384::class,
+ 'RS512' => RS512::class,
+ 'ES256' => ES256::class,
+ 'ES384' => ES384::class,
+ 'ES512' => ES512::class,
+ ];
+
+ protected $asymmetric = [
+ 'HS256' => false,
+ 'HS384' => false,
+ 'HS512' => false,
+ 'RS256' => true,
+ 'RS384' => true,
+ 'RS512' => true,
+ 'ES256' => true,
+ 'ES384' => true,
+ 'ES512' => true,
+ ];
+
+ /**
+ * The secret.
+ *
+ * @var string
+ */
+ protected $secret;
+
+ /**
+ * The array of keys.
+ *
+ * @var array
+ */
+ protected $keys;
+
+ /**
+ * The used algorithm.
+ *
+ * @var string
+ */
+ protected $algo;
+
+ /**
+ * The Signer instance.
+ *
+ * @var \Lcobucci\JWT\Signer
+ */
+ protected $signer;
+
+ public function __construct(string $secret, string $algo, array $keys)
+ {
+ $this->secret = $secret;
+ $this->algo = $algo;
+ $this->keys = $keys;
+ }
+
+ /**
+ * Set the algorithm used to sign the token.
+ *
+ * @return $this
+ */
+ public function setAlgo(string $algo)
+ {
+ $this->algo = $algo;
+
+ return $this;
+ }
+
+ /**
+ * Get the algorithm used to sign the token.
+ */
+ public function getAlgo(): string
+ {
+ return $this->algo;
+ }
+
+ /**
+ * Set the secret used to sign the token.
+ *
+ * @return $this
+ */
+ public function setSecret(string $secret)
+ {
+ $this->secret = $secret;
+
+ return $this;
+ }
+
+ /**
+ * Get the secret used to sign the token.
+ *
+ * @return string
+ */
+ public function getSecret()
+ {
+ return $this->secret;
+ }
+
+ /**
+ * Set the keys used to sign the token.
+ *
+ * @return $this
+ */
+ public function setKeys(array $keys)
+ {
+ $this->keys = $keys;
+
+ return $this;
+ }
+
+ /**
+ * Get the array of keys used to sign tokens
+ * with an asymmetric algorithm.
+ */
+ public function getKeys(): array
+ {
+ return $this->keys;
+ }
+
+ /**
+ * Get the public key used to sign tokens
+ * with an asymmetric algorithm.
+ *
+ * @return resource|string
+ */
+ public function getPublicKey()
+ {
+ return Arr::get($this->keys, 'public');
+ }
+
+ /**
+ * Get the private key used to sign tokens
+ * with an asymmetric algorithm.
+ *
+ * @return resource|string
+ */
+ public function getPrivateKey()
+ {
+ return Arr::get($this->keys, 'private');
+ }
+
+ /**
+ * Get the passphrase used to sign tokens
+ * with an asymmetric algorithm.
+ */
+ public function getPassphrase(): ?string
+ {
+ return Arr::get($this->keys, 'passphrase');
+ }
+
+ /**
+ * Create a JSON Web Token.
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\JwtException
+ */
+ public function encode(array $payload): string
+ {
+ $builder = $this->getBuilder();
+
+ try {
+ foreach ($payload as $key => $value) {
+ $builder->withClaim($key, $value);
+ }
+ return (string) $builder->getToken($this->getSigner(), $this->getSigningKey());
+ } catch (Exception $e) {
+ throw new JwtException('Could not create token: ' . $e->getMessage(), $e->getCode(), $e);
+ }
+ }
+
+ /**
+ * Decode a JSON Web Token.
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\JwtException
+ */
+ public function decode(string $token): array
+ {
+ $parser = $this->getParser();
+
+ try {
+ $jwt = $parser->parse($token);
+ } catch (Exception $e) {
+ throw new TokenInvalidException('Could not decode token: ' . $e->getMessage(), $e->getCode(), $e);
+ }
+
+ if (! $jwt->verify($this->getSigner(), $this->getVerificationKey())) {
+ throw new TokenInvalidException('Token Signature could not be verified.');
+ }
+
+ return (new Collection($jwt->getClaims()))->map(function ($claim) {
+ return is_object($claim) ? $claim->getValue() : $claim;
+ })->toArray();
+ }
+
+ /**
+ * Get the signer instance.
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\JwtException
+ */
+ protected function getSigner(): Signer
+ {
+ if ($this->signer !== null) {
+ return $this->signer;
+ }
+
+ if (! array_key_exists($this->algo, $this->signers)) {
+ throw new JwtException('The given algorithm could not be found');
+ }
+
+ return $this->signer = new $this->signers[$this->algo]();
+ }
+
+ /**
+ * Get the builder instance.
+ */
+ protected function getBuilder(): Builder
+ {
+ return new Builder();
+ }
+
+ /**
+ * Get the parser instance.
+ */
+ protected function getParser(): Parser
+ {
+ return new Parser();
+ }
+
+ /**
+ * Determine if the algorithm is asymmetric, and thus
+ * requires a public/private key combo.
+ */
+ protected function isAsymmetric(): bool
+ {
+ return $this->asymmetric[$this->algo];
+ }
+
+ /**
+ * Get the key used to sign the tokens.
+ */
+ protected function getSigningKey(): Signer\Key
+ {
+ return $this->isAsymmetric()
+ ? new Signer\Key($this->getPrivateKey(), $this->getPassphrase())
+ : new Signer\Key($this->getSecret());
+ }
+
+ /**
+ * Get the key used to verify the tokens.
+ */
+ protected function getVerificationKey(): Signer\Key
+ {
+ return $this->isAsymmetric()
+ ? new Signer\Key($this->getPublicKey())
+ : new Signer\Key($this->getSecret());
+ }
+}
diff --git a/src/Commands/AbstractGenCommand.php b/src/Commands/AbstractGenCommand.php
new file mode 100644
index 0000000..80d9093
--- /dev/null
+++ b/src/Commands/AbstractGenCommand.php
@@ -0,0 +1,56 @@
+config = $config;
+ }
+
+ public function configure()
+ {
+ parent::configure();
+ $this->setDescription($this->description);
+ $this->addOption('show', 's', InputOption::VALUE_NONE, 'Display the key instead of modifying files');
+ $this->addOption('always-no', null, InputOption::VALUE_NONE, 'Skip generating key if it already exists');
+ $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation when overwriting an existing key');
+ }
+
+ /**
+ * @param null|mixed $default
+ *
+ * @return null|mixed
+ */
+ protected function getOption(string $name, $default = null)
+ {
+ $result = $this->input->getOption($name);
+ return empty($result) ? $default : $result;
+ }
+
+ protected function envFilePath(): string
+ {
+ return BASE_PATH . '/.env';
+ }
+}
diff --git a/src/Commands/GenJwtKeypairCommand.php b/src/Commands/GenJwtKeypairCommand.php
new file mode 100644
index 0000000..68afb9b
--- /dev/null
+++ b/src/Commands/GenJwtKeypairCommand.php
@@ -0,0 +1,129 @@
+ ['private_key_type' => OPENSSL_KEYTYPE_RSA, 'digest_alg' => 'SHA256', 'private_key_bits' => 4096],
+ 'RS384' => ['private_key_type' => OPENSSL_KEYTYPE_RSA, 'digest_alg' => 'SHA384', 'private_key_bits' => 4096],
+ 'RS512' => ['private_key_type' => OPENSSL_KEYTYPE_RSA, 'digest_alg' => 'SHA512', 'private_key_bits' => 4096],
+ 'ES256' => ['private_key_type' => OPENSSL_KEYTYPE_EC, 'digest_alg' => 'SHA256', 'curve_name' => 'secp256k1'],
+ 'ES384' => ['private_key_type' => OPENSSL_KEYTYPE_EC, 'digest_alg' => 'SHA384', 'curve_name' => 'secp384r1'],
+ 'ES512' => ['private_key_type' => OPENSSL_KEYTYPE_EC, 'digest_alg' => 'SHA512', 'curve_name' => 'secp521r1'],
+ ];
+
+ public function handle()
+ {
+ [, $config] = $this->choiceAlgorithm();
+ $passphrase = $this->setPassphrase();
+
+ [$privateKey, $publicKey] = $this->generateKeypair($config, $passphrase);
+
+ if (! empty($passphrase)) {
+ $passphrase = base64_encode($passphrase);
+ }
+
+ if ($this->getOption('show')) {
+ $this->displayKey($privateKey, $publicKey, $passphrase);
+ return;
+ }
+
+ if (file_exists($path = $this->envFilePath()) === false) {
+ $this->displayKey($privateKey, $publicKey, $passphrase);
+ return;
+ }
+
+ if (Str::contains(file_get_contents($path), ['JWT_PRIVATE_KEY', 'JWT_PUBLIC_KEY', 'JWT_PASSPHRASE'])) {
+ if ($this->getOption('always-no')) {
+ $this->comment('The key pair or some part of it already exists. Skipping...');
+ return;
+ }
+
+ if ($this->isConfirmed() === false) {
+ $this->comment('Phew... No changes were made to your key pair.');
+ return;
+ }
+
+ $force = true;
+ } else {
+ $force = false;
+ }
+
+ foreach (['privateKey', 'publicKey', 'passphrase'] as $name) {
+ $this->writeEnv($path, $name, ${$name}, $force);
+ }
+
+ $this->info('JWT key pair set successfully.');
+ }
+
+ protected function writeEnv(string $path, string $name, ?string $value, bool $force)
+ {
+ $envKey = 'JWT_' . Str::upper(Str::snake($name));
+ $envValue = empty($value) ? '(null)' : '"' . str_replace("\n", '\\n', $value) . '"';
+
+ if (Str::contains(file_get_contents($path), $envKey) === false) {
+ file_put_contents($path, "{$envKey}={$envValue}\n", FILE_APPEND);
+ } elseif ($force) {
+ file_put_contents($path, preg_replace(
+ "~{$envKey}=[^\n]*~",
+ "{$envKey}={$envValue}",
+ file_get_contents($path)
+ ));
+ }
+ }
+
+ protected function choiceAlgorithm(): array
+ {
+ $algo = $this->choice('Select algorithm', array_keys($this->configs));
+ return [$algo, $this->configs[$algo]];
+ }
+
+ protected function setPassphrase(): ?string
+ {
+ $random = $this->choice('Use random passphrase', ['Yes', 'No']);
+ if ($random === 'Yes') {
+ return random_bytes(16);
+ }
+ return $this->ask('Set passphrase (can be empty)');
+ }
+
+ protected function generateKeypair(array $config, ?string $passphrase = null): array
+ {
+ $res = openssl_pkey_new($config);
+ openssl_pkey_export($res, $privateKey, $passphrase);
+ $publicKey = openssl_pkey_get_details($res)['key'];
+ return [$privateKey, $publicKey];
+ }
+
+ protected function isConfirmed(): bool
+ {
+ return $this->getOption('force') ? true : $this->confirm(
+ 'Are you sure you want to override the key pair? This will invalidate all existing tokens.'
+ );
+ }
+
+ protected function displayKey(string $privateKey, string $publicKey, ?string $passphrase): void
+ {
+ $this->info('Private Key:');
+ $this->comment($privateKey);
+ $this->info('Public Key:');
+ $this->comment($publicKey);
+ $this->info('Passphrase (base64 encoded):');
+ $this->comment(empty($passphrase) ? 'No Passphrase' : $passphrase);
+ }
+}
diff --git a/src/Commands/GenJwtSecretCommand.php b/src/Commands/GenJwtSecretCommand.php
new file mode 100644
index 0000000..dddf156
--- /dev/null
+++ b/src/Commands/GenJwtSecretCommand.php
@@ -0,0 +1,69 @@
+getOption('show')) {
+ $this->comment($key);
+ return;
+ }
+
+ if (file_exists($path = $this->envFilePath()) === false) {
+ $this->displayKey($key);
+ return;
+ }
+
+ if (Str::contains(file_get_contents($path), 'JWT_SECRET') === false) {
+ file_put_contents($path, "\nJWT_SECRET={$key}\n", FILE_APPEND);
+ } else {
+ if ($this->getOption('always-no')) {
+ $this->comment('Secret key already exists. Skipping...');
+ return;
+ }
+
+ if ($this->isConfirmed() === false) {
+ $this->comment('Phew... No changes were made to your secret key.');
+ return;
+ }
+
+ file_put_contents($path, preg_replace(
+ "~JWT_SECRET=[^\n]*~",
+ "JWT_SECRET=\"{$key}\"",
+ file_get_contents($path)
+ ));
+ }
+
+ $this->displayKey($key);
+ }
+
+ protected function displayKey(string $key): void
+ {
+ $this->info("JWT secret [{$key}] (base64 encoded) set successfully.");
+ }
+
+ protected function isConfirmed(): bool
+ {
+ return $this->getOption('force') ? true : $this->confirm(
+ 'Are you sure you want to override the key? This will invalidate all existing tokens.'
+ );
+ }
+}
diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php
new file mode 100644
index 0000000..425861d
--- /dev/null
+++ b/src/ConfigProvider.php
@@ -0,0 +1,50 @@
+ [
+ ManagerInterface::class => ManagerFactory::class,
+ TokenValidatorInterface::class => TokenValidator::class,
+ PayloadValidatorInterface::class => PayloadValidator::class,
+ RequestParserInterface::class => RequestParserFactory::class,
+ JwtFactoryInterface::class => JwtFactory::class,
+ ],
+ 'commands' => [
+ GenJwtSecretCommand::class,
+ GenJwtKeypairCommand::class,
+ ],
+ 'publish' => [
+ [
+ 'id' => 'config',
+ 'description' => 'The config for HyperfExt\\Jwt.',
+ 'source' => __DIR__ . '/../publish/jwt.php',
+ 'destination' => BASE_PATH . '/config/autoload/jwt.php',
+ ],
+ ],
+ ];
+ }
+}
diff --git a/src/Contracts/ClaimInterface.php b/src/Contracts/ClaimInterface.php
new file mode 100644
index 0000000..65b4bb8
--- /dev/null
+++ b/src/Contracts/ClaimInterface.php
@@ -0,0 +1,49 @@
+customClaims = $customClaims;
+
+ return $this;
+ }
+
+ /**
+ * Get the custom claims.
+ */
+ public function getCustomClaims(): array
+ {
+ return $this->customClaims;
+ }
+}
diff --git a/src/Exceptions/InvalidClaimException.php b/src/Exceptions/InvalidClaimException.php
new file mode 100644
index 0000000..2b1cd93
--- /dev/null
+++ b/src/Exceptions/InvalidClaimException.php
@@ -0,0 +1,27 @@
+getName() . ']', $code, $previous);
+ }
+}
diff --git a/src/Exceptions/InvalidConfigException.php b/src/Exceptions/InvalidConfigException.php
new file mode 100644
index 0000000..42b93ea
--- /dev/null
+++ b/src/Exceptions/InvalidConfigException.php
@@ -0,0 +1,15 @@
+manager = $manager;
+ $this->requestParser = $requestParser;
+ $this->request = $request;
+ }
+
+ /**
+ * Magically call the Jwt Manager.
+ *
+ * @throws \BadMethodCallException
+ *
+ * @return mixed
+ */
+ public function __call(string $method, array $parameters)
+ {
+ if (method_exists($this->manager, $method)) {
+ return call_user_func_array([$this->manager, $method], $parameters);
+ }
+
+ throw new BadMethodCallException("Method [{$method}] does not exist.");
+ }
+
+ /**
+ * Generate a token for a given subject.
+ */
+ public function fromSubject(JwtSubjectInterface $subject): string
+ {
+ $payload = $this->makePayload($subject);
+
+ return $this->manager->encode($payload)->get();
+ }
+
+ /**
+ * Alias to generate a token for a given user.
+ */
+ public function fromUser(JwtSubjectInterface $user): string
+ {
+ return $this->fromSubject($user);
+ }
+
+ /**
+ * Refresh an expired token.
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\JwtException
+ */
+ public function refresh(bool $forceForever = false): string
+ {
+ $this->requireToken();
+
+ return $this->token = $this->manager
+ ->refresh($this->token, $forceForever, array_merge(
+ $this->getCustomClaims(),
+ ($prv = $this->getPayload()->get('prv')) ? ['prv' => $prv] : []
+ ))
+ ->get();
+ }
+
+ /**
+ * Invalidate a token (add it to the blacklist).
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\JwtException
+ * @return $this
+ */
+ public function invalidate(bool $forceForever = false)
+ {
+ $this->requireToken();
+
+ $this->manager->invalidate($this->token, $forceForever);
+
+ return $this;
+ }
+
+ /**
+ * Alias to get the payload, and as a result checks that
+ * the token is valid i.e. not expired or blacklisted.
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\JwtException
+ */
+ public function checkOrFail(): Payload
+ {
+ return $this->getPayload();
+ }
+
+ /**
+ * Check that the token is valid.
+ *
+ * @return bool|\HyperfExt\Jwt\Payload
+ */
+ public function check(bool $getPayload = false)
+ {
+ try {
+ $payload = $this->checkOrFail();
+ } catch (JwtException $e) {
+ return false;
+ }
+
+ return $getPayload ? $payload : true;
+ }
+
+ /**
+ * Get the token.
+ */
+ public function getToken(): ?Token
+ {
+ if ($this->token === null) {
+ try {
+ $this->parseToken();
+ } catch (JwtException $e) {
+ $this->token = null;
+ }
+ }
+
+ return $this->token;
+ }
+
+ /**
+ * Parse the token from the request.
+ *
+ *@throws \HyperfExt\Jwt\Exceptions\JwtException
+ * @return $this
+ */
+ public function parseToken()
+ {
+ if (! $token = $this->getRequestParser()->parseToken($this->request)) {
+ throw new JwtException('The token could not be parsed from the request');
+ }
+
+ return $this->setToken($token);
+ }
+
+ /**
+ * Get the raw Payload instance.
+ * @throws \HyperfExt\Jwt\Exceptions\JwtException
+ */
+ public function getPayload(): Payload
+ {
+ $this->requireToken();
+
+ return $this->manager->decode($this->token);
+ }
+
+ /**
+ * Convenience method to get a claim value.
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\JwtException
+ * @return mixed
+ */
+ public function getClaim(string $claim)
+ {
+ return $this->getPayload()->get($claim);
+ }
+
+ /**
+ * Create a Payload instance.
+ */
+ public function makePayload(JwtSubjectInterface $subject): Payload
+ {
+ return $this->getPayloadFactory()->make($this->getClaimsArray($subject));
+ }
+
+ /**
+ * Check if the subject model matches the one saved in the token.
+ *
+ * @param object|string $model
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\JwtException
+ */
+ public function checkSubjectModel($model): bool
+ {
+ if (($prv = $this->getPayload()->get('prv')) === null) {
+ return true;
+ }
+
+ return $this->hashSubjectModel($model) === $prv;
+ }
+
+ /**
+ * Set the token.
+ *
+ * @param \HyperfExt\Jwt\Token|string $token
+ *
+ * @return $this
+ */
+ public function setToken($token)
+ {
+ $this->token = $token instanceof Token ? $token : new Token($token);
+
+ return $this;
+ }
+
+ /**
+ * Unset the current token.
+ *
+ * @return $this
+ */
+ public function unsetToken()
+ {
+ $this->token = null;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setRequest(ServerRequestInterface $request)
+ {
+ $this->request = $request;
+
+ return $this;
+ }
+
+ /**
+ * Set whether the subject should be "locked".
+ *
+ * @return $this
+ */
+ public function setLockSubject(bool $lock)
+ {
+ $this->lockSubject = $lock;
+
+ return $this;
+ }
+
+ /**
+ * Get the Manager instance.
+ */
+ public function getManager(): Manager
+ {
+ return $this->manager;
+ }
+
+ /**
+ * Get the Parser instance.
+ */
+ public function getRequestParser(): RequestParserInterface
+ {
+ return $this->requestParser;
+ }
+
+ /**
+ * Get the Payload Factory.
+ */
+ public function getPayloadFactory(): PayloadFactory
+ {
+ return $this->manager->getPayloadFactory();
+ }
+
+ /**
+ * Get the Blacklist.
+ */
+ public function getBlacklist(): Blacklist
+ {
+ return $this->manager->getBlacklist();
+ }
+
+ /**
+ * Build the claims array and return it.
+ */
+ protected function getClaimsArray(JwtSubjectInterface $subject): array
+ {
+ return array_merge(
+ $this->getClaimsForSubject($subject),
+ $subject->getJwtCustomClaims(), // custom claims from JwtSubject method
+ $this->customClaims // custom claims from inline setter
+ );
+ }
+
+ /**
+ * Get the claims associated with a given subject.
+ */
+ protected function getClaimsForSubject(JwtSubjectInterface $subject): array
+ {
+ return array_merge([
+ 'sub' => $subject->getJwtIdentifier(),
+ ], $this->lockSubject ? ['prv' => $this->hashSubjectModel($subject)] : []);
+ }
+
+ /**
+ * Hash the subject model and return it.
+ *
+ * @param object|string $model
+ */
+ protected function hashSubjectModel($model): string
+ {
+ return sha1(is_object($model) ? get_class($model) : (string) $model);
+ }
+
+ /**
+ * Ensure that a token is available.
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\JwtException
+ */
+ protected function requireToken()
+ {
+ if (! $this->token) {
+ throw new JwtException('A token is required');
+ }
+ }
+}
diff --git a/src/JwtFactory.php b/src/JwtFactory.php
new file mode 100644
index 0000000..d7a46e3
--- /dev/null
+++ b/src/JwtFactory.php
@@ -0,0 +1,29 @@
+lockSubject = (bool) $config->get('jwt.lock_subject');
+ }
+
+ public function make(): Jwt
+ {
+ return make(Jwt::class)->setLockSubject($this->lockSubject);
+ }
+}
diff --git a/src/Manager.php b/src/Manager.php
new file mode 100644
index 0000000..84d5976
--- /dev/null
+++ b/src/Manager.php
@@ -0,0 +1,225 @@
+codec = $codec;
+ $this->blacklist = $blacklist;
+ $this->claimFactory = $claimFactory;
+ $this->payloadFactory = $payloadFactory;
+ }
+
+ /**
+ * Encode a Payload and return the Token.
+ */
+ public function encode(Payload $payload): Token
+ {
+ $token = $this->codec->encode($payload->get());
+
+ return new Token($token);
+ }
+
+ /**
+ * Decode a Token and return the Payload.
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\TokenBlacklistedException
+ */
+ public function decode(Token $token, bool $checkBlacklist = true, bool $ignoreExpired = false): Payload
+ {
+ $payload = $this->payloadFactory->make($this->codec->decode($token->get()), $ignoreExpired);
+
+ if ($checkBlacklist and $this->blacklistEnabled and $this->blacklist->has($payload)) {
+ throw new TokenBlacklistedException('The token has been blacklisted');
+ }
+
+ return $payload;
+ }
+
+ /**
+ * Refresh a Token and return a new Token.
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\TokenBlacklistedException
+ * @throws \HyperfExt\Jwt\Exceptions\JwtException
+ */
+ public function refresh(Token $token, bool $forceForever = false, array $customClaims = []): Token
+ {
+ $claims = $this->buildRefreshClaims($this->decode($token, true, true));
+
+ if ($this->blacklistEnabled) {
+ // Invalidate old token
+ $this->invalidate($token, $forceForever);
+ }
+
+ $claims = array_merge($claims, $customClaims);
+
+ // Return the new token
+ return $this->encode($this->payloadFactory->make($claims));
+ }
+
+ /**
+ * Invalidate a Token by adding it to the blacklist.
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\JwtException
+ */
+ public function invalidate(Token $token, bool $forceForever = false): bool
+ {
+ if (! $this->blacklistEnabled) {
+ throw new JwtException('You must have the blacklist enabled to invalidate a token.');
+ }
+
+ return call_user_func(
+ [$this->blacklist, $forceForever ? 'addForever' : 'add'],
+ $this->decode($token, false)
+ );
+ }
+
+ /**
+ * Get the Claim Factory instance.
+ */
+ public function getClaimFactory(): ClaimFactory
+ {
+ return $this->claimFactory;
+ }
+
+ /**
+ * Get the Payload Factory instance.
+ */
+ public function getPayloadFactory(): PayloadFactory
+ {
+ return $this->payloadFactory;
+ }
+
+ /**
+ * Get the JWT codec instance.
+ */
+ public function getCodec(): CodecInterface
+ {
+ return $this->codec;
+ }
+
+ /**
+ * Get the Blacklist instance.
+ */
+ public function getBlacklist(): Blacklist
+ {
+ return $this->blacklist;
+ }
+
+ /**
+ * Set whether the blacklist is enabled.
+ *
+ * @return $this
+ */
+ public function setBlacklistEnabled(bool $enabled)
+ {
+ $this->blacklistEnabled = $enabled;
+
+ return $this;
+ }
+
+ /**
+ * Set the claims to be persisted when refreshing a token.
+ *
+ * @return $this
+ */
+ public function setPersistentClaims(array $claims)
+ {
+ $this->persistentClaims = $claims;
+
+ return $this;
+ }
+
+ /**
+ * Get the claims to be persisted when refreshing a token.
+ */
+ public function getPersistentClaims(): array
+ {
+ return $this->persistentClaims;
+ }
+
+ /**
+ * Build the claims to go into the refreshed token.
+ *
+ * @param \HyperfExt\Jwt\Payload $payload
+ *
+ * @return array
+ */
+ protected function buildRefreshClaims(Payload $payload)
+ {
+ // Get the claims to be persisted from the payload
+ $persistentClaims = Arr::only($payload->toArray(), $this->persistentClaims);
+
+ // persist the relevant claims
+ return array_merge(
+ $persistentClaims,
+ [
+ 'sub' => $payload['sub'],
+ 'iat' => $payload['iat'],
+ ]
+ );
+ }
+}
diff --git a/src/ManagerFactory.php b/src/ManagerFactory.php
new file mode 100644
index 0000000..ba68faa
--- /dev/null
+++ b/src/ManagerFactory.php
@@ -0,0 +1,82 @@
+get(ConfigInterface::class)->get('jwt');
+ if (empty($config)) {
+ throw new InvalidConfigException(sprintf('JWT config is not defined.'));
+ }
+
+ $this->config = $config;
+
+ $codec = $this->resolveCodec();
+ $blacklist = $this->resolveBlacklist();
+ $claimFactory = $this->resolverClaimFactory();
+ $payloadFactory = $this->resolverPayloadFactory($claimFactory);
+
+ return make(Manager::class, compact('codec', 'blacklist', 'claimFactory', 'payloadFactory'))
+ ->setBlacklistEnabled($this->config['blacklist_enabled']);
+ }
+
+ private function resolveCodec(): CodecInterface
+ {
+ $secret = base64_decode($this->config['secret'] ?? '');
+ $algo = $this->config['algo'] ?? 'HS256';
+ $keys = $this->config['keys'] ?? [];
+ if (! empty($keys)) {
+ $keys['passphrase'] = empty($keys['passphrase']) ? null : base64_decode($keys['passphrase']);
+ }
+ return make(Codec::class, compact('secret', 'algo', 'keys'));
+ }
+
+ private function resolveBlacklist(): Blacklist
+ {
+ $storageClass = $this->config['blacklist_storage'] ?? HyperfCache::class;
+ $storage = make($storageClass, [
+ 'tag' => 'jwt.default',
+ ]);
+ $gracePeriod = $this->config['blacklist_grace_period'];
+ $refreshTtl = $this->config['refresh_ttl'];
+
+ return make(Blacklist::class, compact('storage', 'gracePeriod', 'refreshTtl'));
+ }
+
+ private function resolverClaimFactory(): ClaimFactory
+ {
+ $ttl = $this->config['ttl'];
+ $refreshTtl = $this->config['refresh_ttl'];
+ $leeway = $this->config['leeway'];
+
+ return make(ClaimFactory::class, compact('ttl', 'refreshTtl', 'leeway'));
+ }
+
+ private function resolverPayloadFactory(ClaimFactory $claimFactory): PayloadFactory
+ {
+ return make(PayloadFactory::class, compact('claimFactory'))
+ ->setTtl($this->config['ttl']);
+ }
+}
diff --git a/src/Payload.php b/src/Payload.php
new file mode 100644
index 0000000..ab3d0a5
--- /dev/null
+++ b/src/Payload.php
@@ -0,0 +1,248 @@
+validator = ApplicationContext::getContainer()->get(PayloadValidatorInterface::class);
+ $this->claims = $this->validator->check($claims, $ignoreExpired);
+ }
+
+ /**
+ * Get the payload as a string.
+ */
+ public function __toString(): string
+ {
+ return $this->toJson();
+ }
+
+ /**
+ * Invoke the Payload as a callable function.
+ *
+ * @param mixed $claim
+ *
+ * @return mixed
+ */
+ public function __invoke($claim = null)
+ {
+ return $this->get($claim);
+ }
+
+ /**
+ * Magically get a claim value.
+ *
+ * @throws \BadMethodCallException
+ * @return mixed
+ */
+ public function __call(string $method, array $parameters)
+ {
+ if (preg_match('/get(.+)\b/i', $method, $matches)) {
+ foreach ($this->claims as $claim) {
+ if (get_class($claim) === 'HyperfExt\\Jwt\\Claims\\' . $matches[1]) {
+ return $claim->getValue();
+ }
+ }
+ }
+
+ throw new BadMethodCallException(sprintf('The claim [%s] does not exist on the payload.', $method));
+ }
+
+ /**
+ * Get the array of claim instances.
+ */
+ public function getClaims(): Collection
+ {
+ return $this->claims;
+ }
+
+ /**
+ * Checks if a payload matches some expected values.
+ */
+ public function matches(array $values, bool $strict = false): bool
+ {
+ if (empty($values)) {
+ return false;
+ }
+
+ $claims = $this->getClaims();
+
+ foreach ($values as $key => $value) {
+ if (! $claims->has($key) or ! $claims->get($key)->matches($value, $strict)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if a payload strictly matches some expected values.
+ */
+ public function matchesStrict(array $values): bool
+ {
+ return $this->matches($values, true);
+ }
+
+ /**
+ * Get the payload.
+ *
+ * @param mixed $claim
+ *
+ * @return mixed
+ */
+ public function get($claim = null)
+ {
+ $claim = value($claim);
+
+ if ($claim !== null) {
+ if (is_array($claim)) {
+ return array_map([$this, 'get'], $claim);
+ }
+
+ return Arr::get($this->toArray(), $claim);
+ }
+
+ return $this->toArray();
+ }
+
+ /**
+ * Get the underlying Claim instance.
+ */
+ public function getInternal(string $claim): AbstractClaim
+ {
+ return $this->claims->getByClaimName($claim);
+ }
+
+ /**
+ * Determine whether the payload has the claim (by instance).
+ */
+ public function has(AbstractClaim $claim): bool
+ {
+ return $this->claims->has($claim->getName());
+ }
+
+ /**
+ * Determine whether the payload has the claim (by key).
+ */
+ public function hasKey(string $claim): bool
+ {
+ return $this->offsetExists($claim);
+ }
+
+ /**
+ * Get the array of claims.
+ */
+ public function toArray(): array
+ {
+ return $this->claims->toPlainArray();
+ }
+
+ /**
+ * Convert the object into something JSON serializable.
+ */
+ public function jsonSerialize(): array
+ {
+ return $this->toArray();
+ }
+
+ /**
+ * Get the payload as JSON.
+ */
+ public function toJson(int $options = JSON_UNESCAPED_SLASHES): string
+ {
+ return json_encode($this->toArray(), $options);
+ }
+
+ /**
+ * Determine if an item exists at an offset.
+ *
+ * @param mixed $key
+ */
+ public function offsetExists($key): bool
+ {
+ return Arr::has($this->toArray(), $key);
+ }
+
+ /**
+ * Get an item at a given offset.
+ *
+ * @param mixed $key
+ *
+ * @return mixed
+ */
+ public function offsetGet($key)
+ {
+ return Arr::get($this->toArray(), $key);
+ }
+
+ /**
+ * Don't allow changing the payload as it should be immutable.
+ *
+ * @param mixed $key
+ * @param mixed $value
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\PayloadException
+ */
+ public function offsetSet($key, $value)
+ {
+ throw new PayloadException('The payload is immutable');
+ }
+
+ /**
+ * Don't allow changing the payload as it should be immutable.
+ *
+ * @param string $key
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\PayloadException
+ */
+ public function offsetUnset($key)
+ {
+ throw new PayloadException('The payload is immutable');
+ }
+
+ /**
+ * Count the number of claims.
+ */
+ public function count(): int
+ {
+ return count($this->toArray());
+ }
+}
diff --git a/src/PayloadFactory.php b/src/PayloadFactory.php
new file mode 100644
index 0000000..800084c
--- /dev/null
+++ b/src/PayloadFactory.php
@@ -0,0 +1,129 @@
+claimFactory = $claimFactory;
+ }
+
+ /**
+ * Create the Payload instance.
+ */
+ public function make(array $claims, bool $ignoreExpired = false): Payload
+ {
+ return new Payload($this->resolveClaims($this->buildClaims($claims)), $ignoreExpired);
+ }
+
+ /**
+ * Set the default claims to be added to the Payload.
+ *
+ * @return $this
+ */
+ public function setDefaultClaims(array $claims)
+ {
+ $this->defaultClaims = $claims;
+
+ return $this;
+ }
+
+ /**
+ * Get the default claims.
+ *
+ * @return string[]
+ */
+ public function getDefaultClaims(): array
+ {
+ return $this->defaultClaims;
+ }
+
+ /**
+ * Helper to set the ttl.
+ *
+ * @return $this
+ */
+ public function setTtl(int $ttl)
+ {
+ $this->claimFactory->setTtl($ttl);
+
+ return $this;
+ }
+
+ /**
+ * Helper to get the ttl.
+ */
+ public function getTtl(): int
+ {
+ return $this->claimFactory->getTtl();
+ }
+
+ /**
+ * Build the default claims.
+ */
+ protected function buildClaims(array $claims): Collection
+ {
+ $collection = new Collection();
+ $defaultClaims = $this->getDefaultClaims();
+
+ // remove the exp claim if it exists and the ttl is null
+ if ($this->claimFactory->getTtl() === null and $key = array_search('exp', $defaultClaims)) {
+ unset($defaultClaims[$key]);
+ }
+
+ // add the default claims
+ foreach ($defaultClaims as $claim) {
+ $collection->put($claim, $this->claimFactory->make($claim));
+ }
+
+ // add custom claims on top, allowing them to overwrite defaults
+ foreach ($claims as $name => $value) {
+ $collection->put($name, $value);
+ }
+
+ return $collection;
+ }
+
+ /**
+ * Build out the Claim DTO's.
+ */
+ protected function resolveClaims(Collection $claims): Collection
+ {
+ return $claims->map(function ($value, $name) {
+ return $value instanceof ClaimInterface ? $value : $this->claimFactory->get($name, $value);
+ });
+ }
+}
diff --git a/src/RequestParser/Handlers/AuthHeaders.php b/src/RequestParser/Handlers/AuthHeaders.php
new file mode 100644
index 0000000..60b6ac8
--- /dev/null
+++ b/src/RequestParser/Handlers/AuthHeaders.php
@@ -0,0 +1,66 @@
+getHeaderLine($this->header);
+
+ if ($header and preg_match('/' . $this->prefix . '\s*(\S+)\b/i', $header, $matches)) {
+ return $matches[1];
+ }
+
+ return null;
+ }
+
+ /**
+ * Set the header name.
+ *
+ * @return $this
+ */
+ public function setHeaderName(string $headerName)
+ {
+ $this->header = $headerName;
+
+ return $this;
+ }
+
+ /**
+ * Set the header prefix.
+ *
+ * @return $this
+ */
+ public function setHeaderPrefix(string $headerPrefix)
+ {
+ $this->prefix = $headerPrefix;
+
+ return $this;
+ }
+}
diff --git a/src/RequestParser/Handlers/Cookies.php b/src/RequestParser/Handlers/Cookies.php
new file mode 100644
index 0000000..dda185f
--- /dev/null
+++ b/src/RequestParser/Handlers/Cookies.php
@@ -0,0 +1,24 @@
+getCookieParams(), $this->key);
+ }
+}
diff --git a/src/RequestParser/Handlers/InputSource.php b/src/RequestParser/Handlers/InputSource.php
new file mode 100644
index 0000000..785e890
--- /dev/null
+++ b/src/RequestParser/Handlers/InputSource.php
@@ -0,0 +1,28 @@
+getParsedBody()) ? $data : [],
+ $this->key
+ );
+ return empty($data) === null ? null : (string) $data;
+ }
+}
diff --git a/src/RequestParser/Handlers/KeyTrait.php b/src/RequestParser/Handlers/KeyTrait.php
new file mode 100644
index 0000000..fc920f4
--- /dev/null
+++ b/src/RequestParser/Handlers/KeyTrait.php
@@ -0,0 +1,45 @@
+key = $key;
+
+ return $this;
+ }
+
+ /**
+ * Get the key.
+ *
+ * @return string
+ */
+ public function getKey()
+ {
+ return $this->key;
+ }
+}
diff --git a/src/RequestParser/Handlers/QueryString.php b/src/RequestParser/Handlers/QueryString.php
new file mode 100644
index 0000000..7649d56
--- /dev/null
+++ b/src/RequestParser/Handlers/QueryString.php
@@ -0,0 +1,25 @@
+getQueryParams(), $this->key);
+ return empty($data) === null ? null : (string) $data;
+ }
+}
diff --git a/src/RequestParser/Handlers/RouteParams.php b/src/RequestParser/Handlers/RouteParams.php
new file mode 100644
index 0000000..6939617
--- /dev/null
+++ b/src/RequestParser/Handlers/RouteParams.php
@@ -0,0 +1,27 @@
+route($this->key);
+ }
+}
diff --git a/src/RequestParser/RequestParser.php b/src/RequestParser/RequestParser.php
new file mode 100644
index 0000000..c4a4f2e
--- /dev/null
+++ b/src/RequestParser/RequestParser.php
@@ -0,0 +1,57 @@
+handlers = $handlers;
+ }
+
+ public function getHandlers(): array
+ {
+ return $this->handlers;
+ }
+
+ public function setHandlers(array $handlers)
+ {
+ $this->handlers = $handlers;
+
+ return $this;
+ }
+
+ public function parseToken(ServerRequestInterface $request): ?string
+ {
+ foreach ($this->handlers as $handler) {
+ if ($token = $handler->parse($request)) {
+ return $token;
+ }
+ }
+ return null;
+ }
+
+ public function hasToken(ServerRequestInterface $request): bool
+ {
+ return $this->parseToken($request) !== null;
+ }
+}
diff --git a/src/RequestParser/RequestParserFactory.php b/src/RequestParser/RequestParserFactory.php
new file mode 100644
index 0000000..0a87fa4
--- /dev/null
+++ b/src/RequestParser/RequestParserFactory.php
@@ -0,0 +1,31 @@
+setHandlers([
+ new AuthHeaders(),
+ new QueryString(),
+ new InputSource(),
+ new RouteParams(),
+ new Cookies(),
+ ]);
+ }
+}
diff --git a/src/Storage/HyperfCache.php b/src/Storage/HyperfCache.php
new file mode 100644
index 0000000..084f834
--- /dev/null
+++ b/src/Storage/HyperfCache.php
@@ -0,0 +1,77 @@
+cache = $cache;
+ $this->tag = $tag;
+ }
+
+ public function add(string $key, $value, int $ttl)
+ {
+ $this->cache->set($this->resolveKey($key), $value, $ttl);
+ }
+
+ public function forever(string $key, $value)
+ {
+ $this->cache->set($this->resolveKey($key), $value);
+ }
+
+ public function get(string $key)
+ {
+ return $this->cache->get($this->resolveKey($key));
+ }
+
+ public function destroy(string $key): bool
+ {
+ return $this->cache->delete($this->resolveKey($key));
+ }
+
+ public function flush(): void
+ {
+ method_exists($cache = $this->cache, 'clearPrefix')
+ ? $cache->clearPrefix($this->tag)
+ : $cache->clear();
+ }
+
+ protected function cache(): CacheInterface
+ {
+ return $this->cache;
+ }
+
+ protected function resolveKey(string $key)
+ {
+ return $this->tag . '.' . $key;
+ }
+}
diff --git a/src/Token.php b/src/Token.php
new file mode 100644
index 0000000..4a868ed
--- /dev/null
+++ b/src/Token.php
@@ -0,0 +1,52 @@
+validator = ApplicationContext::getContainer()->get(TokenValidatorInterface::class);
+ $this->value = (string) $this->validator->check($value);
+ }
+
+ /**
+ * Get the token when casting to string.
+ */
+ public function __toString(): string
+ {
+ return $this->get();
+ }
+
+ /**
+ * Get the token.
+ */
+ public function get(): string
+ {
+ return $this->value;
+ }
+}
diff --git a/src/Utils.php b/src/Utils.php
new file mode 100644
index 0000000..3cbbc81
--- /dev/null
+++ b/src/Utils.php
@@ -0,0 +1,56 @@
+timezone('UTC');
+ }
+
+ /**
+ * Checks if a timestamp is in the past.
+ */
+ public static function isPast(int $timestamp, int $leeway = 0): bool
+ {
+ $timestamp = static::timestamp($timestamp);
+
+ return $leeway > 0
+ ? $timestamp->addSeconds($leeway)->isPast()
+ : $timestamp->isPast();
+ }
+
+ /**
+ * Checks if a timestamp is in the future.
+ */
+ public static function isFuture(int $timestamp, int $leeway = 0): bool
+ {
+ $timestamp = static::timestamp($timestamp);
+
+ return $leeway > 0
+ ? $timestamp->subSeconds($leeway)->isFuture()
+ : $timestamp->isFuture();
+ }
+}
diff --git a/src/Validators/PayloadValidator.php b/src/Validators/PayloadValidator.php
new file mode 100644
index 0000000..eb88bb7
--- /dev/null
+++ b/src/Validators/PayloadValidator.php
@@ -0,0 +1,87 @@
+setRequiredClaims($config->get('jwt.required_claims', []));
+ }
+
+ public function check(Collection $value, bool $ignoreExpired = false): Collection
+ {
+ $this->validateStructure($value);
+
+ return $this->validatePayload($value, $ignoreExpired);
+ }
+
+ public function isValid(Collection $value, bool $ignoreExpired = false): bool
+ {
+ try {
+ $this->check($value, $ignoreExpired);
+ } catch (JwtException $e) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Set the required claims.
+ *
+ * @return $this
+ */
+ public function setRequiredClaims(array $claims)
+ {
+ $this->requiredClaims = $claims;
+
+ return $this;
+ }
+
+ /**
+ * Ensure the payload contains the required claims and
+ * the claims have the relevant type.
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\TokenInvalidException
+ */
+ protected function validateStructure(Collection $claims)
+ {
+ if ($this->requiredClaims and ! $claims->hasAllClaims($this->requiredClaims)) {
+ throw new TokenInvalidException('JWT payload does not contain the required claims');
+ }
+ return $this;
+ }
+
+ /**
+ * Validate the payload timestamps.
+ *
+ * @throws \HyperfExt\Jwt\Exceptions\TokenExpiredException
+ * @throws \HyperfExt\Jwt\Exceptions\TokenInvalidException
+ */
+ protected function validatePayload(Collection $claims, bool $ignoreExpired = false): Collection
+ {
+ return $claims->validate($ignoreExpired);
+ }
+}
diff --git a/src/Validators/TokenValidator.php b/src/Validators/TokenValidator.php
new file mode 100644
index 0000000..b1f6a29
--- /dev/null
+++ b/src/Validators/TokenValidator.php
@@ -0,0 +1,61 @@
+validateStructure($value);
+ return $value;
+ }
+
+ /**
+ * Helper function to return a boolean.
+ */
+ public function isValid(string $value): bool
+ {
+ try {
+ $this->check($value);
+ } catch (JwtException $e) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @throws \HyperfExt\Jwt\Exceptions\TokenInvalidException
+ */
+ protected function validateStructure(string $token)
+ {
+ $parts = explode('.', $token);
+
+ if (count($parts) !== 3) {
+ throw new TokenInvalidException('Wrong number of segments');
+ }
+
+ $parts = array_filter(array_map('trim', $parts));
+
+ if (count($parts) !== 3 or implode('.', $parts) !== $token) {
+ throw new TokenInvalidException('Malformed token');
+ }
+
+ return $this;
+ }
+}
diff --git a/tests/AbstractTestCase.php b/tests/AbstractTestCase.php
new file mode 100644
index 0000000..5ed4b4f
--- /dev/null
+++ b/tests/AbstractTestCase.php
@@ -0,0 +1,61 @@
+testNowTimestamp = $now->getTimestamp();
+ $this->container = ApplicationContext::getContainer();
+ $this->container->set(ManagerInterface::class, $this->manager = Mockery::mock(ManagerFactory::class));
+ $this->manager->shouldReceive('getClaimFactory')->andReturn($this->claimFactory = new Factory(3600, 3600 * 24 * 14));
+ }
+
+ public function tearDown()
+ {
+ Carbon::setTestNow();
+ Mockery::close();
+
+ parent::tearDown();
+ }
+}
diff --git a/tests/BlacklistTest.php b/tests/BlacklistTest.php
new file mode 100644
index 0000000..67e3449
--- /dev/null
+++ b/tests/BlacklistTest.php
@@ -0,0 +1,313 @@
+storage = Mockery::mock(StorageInterface::class);
+ $this->blacklist = new Blacklist($this->storage, 0, 3600 * 24 * 14);
+ }
+
+ /** @test */
+ public function itShouldAddAValidTokenToTheBlacklist()
+ {
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp + 3600),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp),
+ new JwtId('foo'),
+ ];
+
+ $collection = Collection::make($claims);
+
+ $payload = new Payload($collection);
+
+ $refreshTtl = 1209660;
+
+ $this->storage->shouldReceive('get')
+ ->with('foo')
+ ->once()
+ ->andReturn([]);
+
+ $this->storage->shouldReceive('add')
+ ->with('foo', ['valid_until' => $this->testNowTimestamp], $refreshTtl + 60)
+ ->once();
+
+ $this->assertTrue($this->blacklist->setRefreshTtl($refreshTtl)->add($payload));
+ }
+
+ /** @test */
+ public function itShouldAddATokenWithNoExpToTheBlacklistForever()
+ {
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp),
+ new JwtId('foo'),
+ ];
+ $collection = Collection::make($claims);
+
+ $payload = new Payload($collection);
+
+ $this->storage->shouldReceive('forever')->with('foo', 'forever')->once();
+
+ $this->assertTrue($this->blacklist->add($payload));
+ }
+
+ /** @test */
+ public function itShouldReturnTrueWhenAddingAnExpiredTokenToTheBlacklist()
+ {
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp - 3600),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp),
+ new JwtId('foo'),
+ ];
+ $collection = Collection::make($claims);
+
+ $payload = new Payload($collection, true);
+
+ $refreshTtl = 1209660;
+
+ $this->storage->shouldReceive('get')
+ ->with('foo')
+ ->once()
+ ->andReturn([]);
+
+ $this->storage->shouldReceive('add')
+ ->with('foo', ['valid_until' => $this->testNowTimestamp], $refreshTtl + 60)
+ ->once();
+
+ $this->assertTrue($this->blacklist->setRefreshTtl($refreshTtl)->add($payload));
+ }
+
+ /** @test */
+ public function itShouldReturnTrueEarlyWhenAddingAnItemAndItAlreadyExists()
+ {
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp - 3600),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp),
+ new JwtId('foo'),
+ ];
+ $collection = Collection::make($claims);
+
+ $payload = new Payload($collection, true);
+
+ $refreshTtl = 1209660;
+
+ $this->storage->shouldReceive('get')
+ ->with('foo')
+ ->once()
+ ->andReturn(['valid_until' => $this->testNowTimestamp]);
+
+ $this->storage->shouldReceive('add')
+ ->with('foo', ['valid_until' => $this->testNowTimestamp], $refreshTtl + 60)
+ ->never();
+
+ $this->assertTrue($this->blacklist->setRefreshTtl($refreshTtl)->add($payload));
+ }
+
+ /** @test */
+ public function itShouldCheckWhetherATokenHasBeenBlacklisted()
+ {
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp + 3600),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp),
+ new JwtId('foobar'),
+ ];
+
+ $collection = Collection::make($claims);
+
+ $payload = new Payload($collection);
+
+ $this->storage->shouldReceive('get')->with('foobar')->once()->andReturn(['valid_until' => $this->testNowTimestamp]);
+
+ $this->assertTrue($this->blacklist->has($payload));
+ }
+
+ public function blacklist_provider()
+ {
+ return [
+ [null],
+ [0],
+ [''],
+ [[]],
+ [['valid_until' => strtotime('+1day')]],
+ ];
+ }
+
+ /**
+ * @test
+ * @dataProvider blacklist_provider
+ *
+ * @param mixed $result
+ */
+ public function itShouldCheckWhetherATokenHasNotBeenBlacklisted($result)
+ {
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp + 3600),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp),
+ new JwtId('foobar'),
+ ];
+
+ $collection = Collection::make($claims);
+
+ $payload = new Payload($collection);
+
+ $this->storage->shouldReceive('get')->with('foobar')->once()->andReturn($result);
+ $this->assertFalse($this->blacklist->has($payload));
+ }
+
+ /** @test */
+ public function itShouldCheckWhetherATokenHasBeenBlacklistedForever()
+ {
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp + 3600),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp),
+ new JwtId('foobar'),
+ ];
+ $collection = Collection::make($claims);
+
+ $payload = new Payload($collection);
+
+ $this->storage->shouldReceive('get')->with('foobar')->once()->andReturn('forever');
+
+ $this->assertTrue($this->blacklist->has($payload));
+ }
+
+ /** @test */
+ public function itShouldCheckWhetherATokenHasBeenBlacklistedWhenTheTokenIsNotBlacklisted()
+ {
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp + 3600),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp),
+ new JwtId('foobar'),
+ ];
+ $collection = Collection::make($claims);
+
+ $payload = new Payload($collection);
+
+ $this->storage->shouldReceive('get')->with('foobar')->once()->andReturn(null);
+
+ $this->assertFalse($this->blacklist->has($payload));
+ }
+
+ /** @test */
+ public function itShouldRemoveATokenFromTheBlacklist()
+ {
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp + 3600),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp),
+ new JwtId('foobar'),
+ ];
+ $collection = Collection::make($claims);
+
+ $payload = new Payload($collection);
+
+ $this->storage->shouldReceive('destroy')->with('foobar')->andReturn(true);
+ $this->assertTrue($this->blacklist->remove($payload));
+ }
+
+ /** @test */
+ public function itShouldSetACustomUniqueKeyForTheBlacklist()
+ {
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp + 3600),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp),
+ new JwtId('foobar'),
+ ];
+ $collection = Collection::make($claims);
+
+ $payload = new Payload($collection);
+
+ $this->storage->shouldReceive('get')->with(1)->once()->andReturn(['valid_until' => $this->testNowTimestamp]);
+
+ $this->assertTrue($this->blacklist->setKey('sub')->has($payload));
+ $this->assertSame(1, $this->blacklist->getKey($payload));
+ }
+
+ /** @test */
+ public function itShouldEmptyTheBlacklist()
+ {
+ $this->storage->shouldReceive('flush');
+ $this->assertTrue($this->blacklist->clear());
+ }
+
+ /** @test */
+ public function itShouldSetAndGetTheBlacklistGracePeriod()
+ {
+ $this->assertInstanceOf(Blacklist::class, $this->blacklist->setGracePeriod(15));
+ $this->assertSame(15, $this->blacklist->getGracePeriod());
+ }
+
+ /** @test */
+ public function itShouldSetAndGetTheBlacklistRefreshTtl()
+ {
+ $this->assertInstanceOf(Blacklist::class, $this->blacklist->setRefreshTtl(15));
+ $this->assertSame(15, $this->blacklist->getRefreshTtl());
+ }
+}
diff --git a/tests/Claims/ClaimTest.php b/tests/Claims/ClaimTest.php
new file mode 100644
index 0000000..e1f83dd
--- /dev/null
+++ b/tests/Claims/ClaimTest.php
@@ -0,0 +1,66 @@
+claim = new Expiration($this->testNowTimestamp);
+ }
+
+ /** @test */
+ public function itShouldThrowAnExceptionWhenPassingAnInvalidValue()
+ {
+ $this->expectExceptionMessage('Invalid value provided for claim [exp]');
+ $this->expectException(\HyperfExt\Jwt\Exceptions\InvalidClaimException::class);
+ $this->claim->setValue('foo');
+ }
+
+ /** @test */
+ public function itShouldConvertTheClaimToAnArray()
+ {
+ $this->assertSame(['exp' => $this->testNowTimestamp], $this->claim->toArray());
+ }
+
+ /** @test */
+ public function itShouldGetTheClaimAsAString()
+ {
+ $this->assertJsonStringEqualsJsonString((string) $this->claim, $this->claim->toJson());
+ }
+
+ /** @test */
+ public function itShouldGetTheObjectAsJson()
+ {
+ $this->assertJsonStringEqualsJsonString(json_encode($this->claim), $this->claim->toJson());
+ }
+
+ /** @test */
+ public function itShouldImplementArrayable()
+ {
+ $this->assertInstanceOf(Arrayable::class, $this->claim);
+ }
+}
diff --git a/tests/Claims/CollectionTest.php b/tests/Claims/CollectionTest.php
new file mode 100644
index 0000000..8db73e9
--- /dev/null
+++ b/tests/Claims/CollectionTest.php
@@ -0,0 +1,71 @@
+getCollection();
+
+ $this->assertSame(array_keys($collection->toArray()), ['sub', 'iss', 'exp', 'nbf', 'iat', 'jti']);
+ }
+
+ /** @test */
+ public function itShouldDetermineIfACollectionContainsAllTheGivenClaims()
+ {
+ $collection = $this->getCollection();
+
+ $this->assertFalse($collection->hasAllClaims(['sub', 'iss', 'exp', 'nbf', 'iat', 'jti', 'abc']));
+ $this->assertFalse($collection->hasAllClaims(['foo', 'bar']));
+ $this->assertFalse($collection->hasAllClaims([]));
+
+ $this->assertTrue($collection->hasAllClaims(['sub', 'iss']));
+ $this->assertTrue($collection->hasAllClaims(['sub', 'iss', 'exp', 'nbf', 'iat', 'jti']));
+ }
+
+ /** @test */
+ public function itShouldGetAClaimInstanceByName()
+ {
+ $collection = $this->getCollection();
+
+ $this->assertInstanceOf(Expiration::class, $collection->getByClaimName('exp'));
+ $this->assertInstanceOf(Subject::class, $collection->getByClaimName('sub'));
+ }
+
+ private function getCollection()
+ {
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp + 3600),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp),
+ new JwtId('foo'),
+ ];
+
+ return new Collection($claims);
+ }
+}
diff --git a/tests/Claims/DatetimeClaimTest.php b/tests/Claims/DatetimeClaimTest.php
new file mode 100644
index 0000000..c1a844a
--- /dev/null
+++ b/tests/Claims/DatetimeClaimTest.php
@@ -0,0 +1,146 @@
+claimsTimestamp = [
+ 'sub' => new Subject(1),
+ 'iss' => new Issuer('http://example.com'),
+ 'exp' => new Expiration($this->testNowTimestamp + 3600),
+ 'nbf' => new NotBefore($this->testNowTimestamp),
+ 'iat' => new IssuedAt($this->testNowTimestamp),
+ 'jti' => new JwtId('foo'),
+ ];
+ }
+
+ /** @test */
+ public function itShouldHandleCarbonClaims()
+ {
+ $testCarbon = Carbon::createFromTimestampUTC($this->testNowTimestamp);
+ $testCarbonCopy = clone $testCarbon;
+
+ $this->assertInstanceOf(Carbon::class, $testCarbon);
+ $this->assertInstanceOf(Datetime::class, $testCarbon);
+ $this->assertInstanceOf(DatetimeInterface::class, $testCarbon);
+
+ $claimsDatetime = [
+ 'sub' => new Subject(1),
+ 'iss' => new Issuer('http://example.com'),
+ 'exp' => new Expiration($testCarbonCopy->addHour()),
+ 'nbf' => new NotBefore($testCarbon),
+ 'iat' => new IssuedAt($testCarbon),
+ 'jti' => new JwtId('foo'),
+ ];
+
+ $payloadTimestamp = new Payload(Collection::make($this->claimsTimestamp));
+ $payloadDatetime = new Payload(Collection::make($claimsDatetime));
+
+ $this->assertEquals($payloadTimestamp, $payloadDatetime);
+ }
+
+ /** @test */
+ public function itShouldHandleDatetimeClaims()
+ {
+ $testDateTime = DateTime::createFromFormat('U', (string) $this->testNowTimestamp);
+ $testDateTimeCopy = clone $testDateTime;
+
+ $this->assertInstanceOf(DateTime::class, $testDateTime);
+ $this->assertInstanceOf(DatetimeInterface::class, $testDateTime);
+
+ $claimsDatetime = [
+ 'sub' => new Subject(1),
+ 'iss' => new Issuer('http://example.com'),
+ 'exp' => new Expiration($testDateTimeCopy->modify('+3600 seconds')),
+ 'nbf' => new NotBefore($testDateTime),
+ 'iat' => new IssuedAt($testDateTime),
+ 'jti' => new JwtId('foo'),
+ ];
+
+ $payloadTimestamp = new Payload(Collection::make($this->claimsTimestamp));
+ $payloadDatetime = new Payload(Collection::make($claimsDatetime));
+
+ $this->assertEquals($payloadTimestamp, $payloadDatetime);
+ }
+
+ /** @test */
+ public function itShouldHandleDatetimeImmutableClaims()
+ {
+ $testDateTimeImmutable = DateTimeImmutable::createFromFormat('U', (string) $this->testNowTimestamp);
+
+ $this->assertInstanceOf(DateTimeImmutable::class, $testDateTimeImmutable);
+ $this->assertInstanceOf(DatetimeInterface::class, $testDateTimeImmutable);
+
+ $claimsDatetime = [
+ 'sub' => new Subject(1),
+ 'iss' => new Issuer('http://example.com'),
+ 'exp' => new Expiration($testDateTimeImmutable->modify('+3600 seconds')),
+ 'nbf' => new NotBefore($testDateTimeImmutable),
+ 'iat' => new IssuedAt($testDateTimeImmutable),
+ 'jti' => new JwtId('foo'),
+ ];
+
+ $payloadTimestamp = new Payload(Collection::make($this->claimsTimestamp));
+ $payloadDatetime = new Payload(Collection::make($claimsDatetime));
+
+ $this->assertEquals($payloadTimestamp, $payloadDatetime);
+ }
+
+ /** @test */
+ public function itShouldHandleDatetintervalClaims()
+ {
+ $testDateInterval = new DateInterval('PT1H');
+
+ $this->assertInstanceOf(DateInterval::class, $testDateInterval);
+
+ $claimsDateInterval = [
+ 'sub' => new Subject(1),
+ 'iss' => new Issuer('http://example.com'),
+ 'exp' => new Expiration($testDateInterval),
+ 'nbf' => new NotBefore($this->testNowTimestamp),
+ 'iat' => new IssuedAt($this->testNowTimestamp),
+ 'jti' => new JwtId('foo'),
+ ];
+
+ $payloadTimestamp = new Payload(Collection::make($this->claimsTimestamp));
+ $payloadDateInterval = new Payload(Collection::make($claimsDateInterval));
+
+ $this->assertEquals($payloadTimestamp, $payloadDateInterval);
+ }
+}
diff --git a/tests/Claims/FactoryTest.php b/tests/Claims/FactoryTest.php
new file mode 100644
index 0000000..2c6ec97
--- /dev/null
+++ b/tests/Claims/FactoryTest.php
@@ -0,0 +1,105 @@
+factory = new Factory(3600, 3600 * 24 * 14);
+ $this->container->set(ServerRequestInterface::class, new Request('GET', 'http://localhost/foo'));
+ }
+
+ /** @test */
+ public function itShouldSetTheTtl()
+ {
+ $this->assertInstanceOf(Factory::class, $this->factory->setTtl(30));
+ }
+
+ /** @test */
+ public function itShouldGetTheTtl()
+ {
+ $this->factory->setTtl($ttl = 30);
+ $this->assertSame($ttl, $this->factory->getTtl());
+ }
+
+ /** @test */
+ public function itShouldGetADefinedClaimInstanceWhenPassingANameAndValue()
+ {
+ $this->assertInstanceOf(Subject::class, $this->factory->get('sub', 1));
+ $this->assertInstanceOf(Issuer::class, $this->factory->get('iss', 'http://example.com'));
+ $this->assertInstanceOf(Expiration::class, $this->factory->get('exp', $this->testNowTimestamp + 3600));
+ $this->assertInstanceOf(NotBefore::class, $this->factory->get('nbf', $this->testNowTimestamp));
+ $this->assertInstanceOf(IssuedAt::class, $this->factory->get('iat', $this->testNowTimestamp));
+ $this->assertInstanceOf(JwtId::class, $this->factory->get('jti', 'foo'));
+ }
+
+ /** @test */
+ public function itShouldGetACustomClaimInstanceWhenPassingANonDefinedNameAndValue()
+ {
+ $this->assertInstanceOf(Custom::class, $this->factory->get('foo', ['bar']));
+ }
+
+ /** @test */
+ public function itShouldMakeAClaimInstanceWithAValue()
+ {
+ $iat = $this->factory->make('iat');
+ $this->assertSame($iat->getValue(), $this->testNowTimestamp);
+ $this->assertInstanceOf(IssuedAt::class, $iat);
+
+ $nbf = $this->factory->make('nbf');
+ $this->assertSame($nbf->getValue(), $this->testNowTimestamp);
+ $this->assertInstanceOf(NotBefore::class, $nbf);
+
+ $iss = $this->factory->make('iss');
+ $this->assertSame($iss->getValue(), 'http://localhost/foo');
+ $this->assertInstanceOf(Issuer::class, $iss);
+
+ $exp = $this->factory->make('exp');
+ $this->assertSame($exp->getValue(), $this->testNowTimestamp + 3600);
+ $this->assertInstanceOf(Expiration::class, $exp);
+
+ $jti = $this->factory->make('jti');
+ $this->assertInstanceOf(JwtId::class, $jti);
+ }
+
+ /** @test */
+ public function itShouldExtendClaimFactoryToAddACustomClaim()
+ {
+ $this->factory->extend('foo', Foo::class);
+
+ $this->assertInstanceOf(Foo::class, $this->factory->get('foo', 'bar'));
+ }
+}
diff --git a/tests/Claims/IssuedAtTest.php b/tests/Claims/IssuedAtTest.php
new file mode 100644
index 0000000..040f595
--- /dev/null
+++ b/tests/Claims/IssuedAtTest.php
@@ -0,0 +1,30 @@
+expectExceptionMessage('Invalid value provided for claim [iat]');
+ $this->expectException(InvalidClaimException::class);
+ new IssuedAt($this->testNowTimestamp + 3600);
+ }
+}
diff --git a/tests/Claims/NotBeforeTest.php b/tests/Claims/NotBeforeTest.php
new file mode 100644
index 0000000..a62ab18
--- /dev/null
+++ b/tests/Claims/NotBeforeTest.php
@@ -0,0 +1,32 @@
+expectExceptionMessage('Invalid value provided for claim [nbf]');
+ $this->expectException(InvalidClaimException::class);
+ new NotBefore('foo');
+ }
+}
diff --git a/tests/CodecTest.php b/tests/CodecTest.php
new file mode 100644
index 0000000..c417640
--- /dev/null
+++ b/tests/CodecTest.php
@@ -0,0 +1,216 @@
+builder = Mockery::mock(Builder::class);
+ $this->parser = Mockery::mock(Parser::class);
+ }
+
+ /** @test */
+ public function itShouldSetTheAlgo()
+ {
+ $codec = $this->getCodec('secret', 'HS256', []);
+ $codec->setAlgo('HS512');
+
+ $this->assertSame('HS512', $codec->getAlgo());
+ }
+
+ /** @test */
+ public function itShouldSetTheSecret()
+ {
+ $codec = $this->getCodec('secret', 'HS256', []);
+ $codec->setSecret('foo');
+
+ $this->assertSame('foo', $codec->getSecret());
+ }
+
+ /** @test */
+ public function itShouldReturnTheTokenWhenPassingAValidPayloadToEncode()
+ {
+ $payload = ['sub' => 1, 'exp' => $this->testNowTimestamp + 3600, 'iat' => $this->testNowTimestamp, 'iss' => '/foo'];
+
+ $this->builder->shouldReceive('withClaim')->times(count($payload));
+ $this->builder->shouldReceive('getToken')->once()->andReturn('foo.bar.baz');
+ $this->builder->shouldReceive('sign')->never();
+
+ $token = $this->getCodec('secret', 'HS256')->encode($payload);
+
+ $this->assertSame('foo.bar.baz', $token);
+ }
+
+ /** @test */
+ public function itShouldThrowAnInvalidExceptionWhenThePayloadCouldNotBeEncoded()
+ {
+ $this->expectExceptionMessage('Could not create token:');
+ $this->expectException(JwtException::class);
+ $payload = ['sub' => 1, 'exp' => $this->testNowTimestamp, 'iat' => $this->testNowTimestamp, 'iss' => '/foo'];
+
+ $this->builder->shouldReceive('withClaim')->times(count($payload));
+ $this->builder->shouldReceive('sign')->never();
+
+ $this->getCodec('secret', 'HS256')->encode($payload);
+ }
+
+ /** @test */
+ public function itShouldReturnThePayloadWhenPassingAValidTokenToDecode()
+ {
+ $payload = ['sub' => 1, 'exp' => $this->testNowTimestamp + 3600, 'iat' => $this->testNowTimestamp, 'iss' => '/foo'];
+
+ $jwt = Mockery::mock(Token::class);
+ $jwt->shouldReceive('verify')->once()->with(Mockery::any(), Mockery::any())->andReturn(true);
+ $jwt->shouldReceive('getClaims')->once()->andReturn($payload);
+ $this->parser->shouldReceive('parse')->once()->with('foo.bar.baz')->andReturn($jwt);
+
+ $this->assertSame($payload, $this->getCodec('secret', 'HS256')->decode('foo.bar.baz'));
+ }
+
+ /** @test */
+ public function itShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecodedDueToABadSignature()
+ {
+ $this->expectException(TokenInvalidException::class);
+ $this->expectExceptionMessage('Token Signature could not be verified.');
+
+ $jwt = Mockery::mock(Token::class);
+ $jwt->shouldReceive('verify')->once()->with(Mockery::any(), Mockery::any())->andReturn(false);
+ $jwt->shouldReceive('getClaims')->never();
+ $this->parser->shouldReceive('parse')->once()->with('foo.bar.baz')->andReturn(Mockery::self());
+
+ $this->getCodec('secret', 'HS256')->decode('foo.bar.baz');
+ }
+
+ /** @test */
+ public function itShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecoded()
+ {
+ $this->expectExceptionMessage('Could not decode token:');
+ $this->expectException(TokenInvalidException::class);
+ $this->parser->shouldReceive('parse')->once()->with('foo.bar.baz')->andThrow(new InvalidArgumentException());
+ $this->parser->shouldReceive('verify')->never();
+ $this->parser->shouldReceive('getClaims')->never();
+
+ $this->getCodec('secret', 'HS256')->decode('foo.bar.baz');
+ }
+
+ /** @test */
+ public function itShouldGenerateATokenWhenUsingAnRsaAlgorithm()
+ {
+ $codec = $this->getCodec(
+ 'does_not_matter',
+ 'RS256',
+ ['private' => $this->getDummyPrivateKey(), 'public' => $this->getDummyPublicKey()]
+ );
+
+ $payload = ['sub' => 1, 'exp' => $this->testNowTimestamp + 3600, 'iat' => $this->testNowTimestamp, 'iss' => '/foo'];
+
+ $this->builder->shouldReceive('withClaim')->times(count($payload));
+ $this->builder->shouldReceive('getToken')->once()->andReturn('foo.bar.baz');
+
+ $token = $codec->encode($payload);
+
+ $this->assertSame('foo.bar.baz', $token);
+ }
+
+ /** @test */
+ public function itShouldThrowAExceptionWhenTheAlgorithmPassedIsInvalid()
+ {
+ $this->expectException(JwtException::class);
+ $this->expectExceptionMessage('The given algorithm could not be found');
+
+ $jwt = Mockery::mock(Token::class);
+ $this->parser->shouldReceive('parse')->andReturn($jwt);
+ $this->parser->shouldReceive('verify')->never();
+
+ $this->getCodec('secret', 'AlgorithmWrong')->decode('foo.bar.baz');
+ }
+
+ /**
+ * @test
+ */
+ public function itShouldReturnThePublicKey()
+ {
+ $codec = $this->getCodec(
+ 'does_not_matter',
+ 'RS256',
+ $keys = ['private' => $this->getDummyPrivateKey(), 'public' => $this->getDummyPublicKey()]
+ );
+
+ $this->assertSame($keys['public'], $codec->getPublicKey());
+ }
+
+ /**
+ * @test
+ */
+ public function itShouldReturnTheKeys()
+ {
+ $codec = $this->getCodec(
+ 'does_not_matter',
+ 'RS256',
+ $keys = ['private' => $this->getDummyPrivateKey(), 'public' => $this->getDummyPublicKey()]
+ );
+
+ $this->assertSame($keys, $codec->getKeys());
+ }
+
+ /**
+ * @param $secret
+ * @param $algo
+ *
+ * @return \HyperfExt\Jwt\Codec|\PHPUnit\Framework\MockObject\MockObject
+ */
+ public function getCodec($secret, $algo, array $keys = [])
+ {
+ $codec = $this->getMockBuilder(Codec::class)
+ ->setMethods(['getBuilder', 'getParser'])
+ ->setConstructorArgs([$secret, $algo, $keys])
+ ->getMock();
+ $codec->method('getBuilder')->willReturn($this->builder);
+ $codec->method('getParser')->willReturn($this->parser);
+ return $codec;
+ }
+
+ public function getDummyPrivateKey()
+ {
+ return file_get_contents(__DIR__ . '/Keys/id_rsa');
+ }
+
+ public function getDummyPublicKey()
+ {
+ return file_get_contents(__DIR__ . '/Keys/id_rsa.pub');
+ }
+}
diff --git a/tests/Fixtures/Foo.php b/tests/Fixtures/Foo.php
new file mode 100644
index 0000000..9630b6c
--- /dev/null
+++ b/tests/Fixtures/Foo.php
@@ -0,0 +1,26 @@
+payload = $this->getTestPayload();
+ }
+
+ /** @test */
+ public function itShouldThrowAnExceptionWhenTryingToAddToThePayload()
+ {
+ $this->expectException(PayloadException::class);
+ $this->expectExceptionMessage('The payload is immutable');
+ $this->payload['foo'] = 'bar';
+ }
+
+ /** @test */
+ public function itShouldThrowAnExceptionWhenTryingToRemoveAKeyFromThePayload()
+ {
+ $this->expectExceptionMessage('The payload is immutable');
+ $this->expectException(PayloadException::class);
+ unset($this->payload['foo']);
+ }
+
+ /** @test */
+ public function itShouldCastThePayloadToAStringAsJson()
+ {
+ $this->assertSame((string) $this->payload, json_encode($this->payload->get(), JSON_UNESCAPED_SLASHES));
+ $this->assertJsonStringEqualsJsonString((string) $this->payload, json_encode($this->payload->get()));
+ }
+
+ /** @test */
+ public function itShouldAllowArrayAccessOnThePayload()
+ {
+ $this->assertTrue(isset($this->payload['iat']));
+ $this->assertSame($this->payload['sub'], 1);
+ $this->assertArrayHasKey('exp', $this->payload);
+ }
+
+ /** @test */
+ public function itShouldGetPropertiesOfPayloadViaGetMethod()
+ {
+ $this->assertInternalType('array', $this->payload->get());
+ $this->assertSame($this->payload->get('sub'), 1);
+
+ $this->assertSame(
+ $this->payload->get(function () {
+ return 'jti';
+ }),
+ 'foo'
+ );
+ }
+
+ /** @test */
+ public function itShouldGetMultiplePropertiesWhenPassingAnArrayToTheGetMethod()
+ {
+ $values = $this->payload->get(['sub', 'jti']);
+
+ $sub = $values[0];
+ $jti = $values[1];
+
+ $this->assertIsArray($values);
+ $this->assertSame($sub, 1);
+ $this->assertSame($jti, 'foo');
+ }
+
+ /** @test */
+ public function itShouldDetermineWhetherThePayloadHasAClaim()
+ {
+ $this->assertTrue($this->payload->has(new Subject(1)));
+ $this->assertFalse($this->payload->has(new Audience(1)));
+ }
+
+ /** @test */
+ public function itShouldMagicallyGetAProperty()
+ {
+ $sub = $this->payload->getSubject();
+ $jti = $this->payload->getJwtId();
+ $iss = $this->payload->getIssuer();
+
+ $this->assertSame($sub, 1);
+ $this->assertSame($jti, 'foo');
+ $this->assertSame($iss, 'http://example.com');
+ }
+
+ /** @test */
+ public function itShouldInvokeTheInstanceAsACallable()
+ {
+ $payload = $this->payload;
+
+ $sub = $payload('sub');
+ $jti = $payload('jti');
+ $iss = $payload('iss');
+
+ $this->assertSame($sub, 1);
+ $this->assertSame($jti, 'foo');
+ $this->assertSame($iss, 'http://example.com');
+
+ $this->assertSame($payload(), $this->payload->toArray());
+ }
+
+ /** @test */
+ public function itShouldThrowAnExceptionWhenMagicallyGettingAPropertyThatDoesNotExist()
+ {
+ $this->expectExceptionMessage('The claim [getFoo] does not exist on the payload.');
+ $this->expectException(\BadMethodCallException::class);
+ $this->payload->getFoo();
+ }
+
+ /** @test */
+ public function itShouldGetTheClaims()
+ {
+ $claims = $this->payload->getClaims();
+
+ $this->assertInstanceOf(Expiration::class, $claims['exp']);
+ $this->assertInstanceOf(JwtId::class, $claims['jti']);
+ $this->assertInstanceOf(Subject::class, $claims['sub']);
+
+ $this->assertContainsOnlyInstancesOf(ClaimInterface::class, $claims);
+ }
+
+ /** @test */
+ public function itShouldGetTheObjectAsJson()
+ {
+ $this->assertJsonStringEqualsJsonString(json_encode($this->payload), $this->payload->toJson());
+ }
+
+ /** @test */
+ public function itShouldCountTheClaims()
+ {
+ $this->assertSame(6, $this->payload->count());
+ $this->assertCount(6, $this->payload);
+ }
+
+ /** @test */
+ public function itShouldMatchValues()
+ {
+ $values = $this->payload->toArray();
+ $values['sub'] = (string) $values['sub'];
+
+ $this->assertTrue($this->payload->matches($values));
+ }
+
+ /** @test */
+ public function itShouldMatchStrictValues()
+ {
+ $values = $this->payload->toArray();
+
+ $this->assertTrue($this->payload->matchesStrict($values));
+ $this->assertTrue($this->payload->matches($values, true));
+ }
+
+ /** @test */
+ public function itShouldNotMatchEmptyValues()
+ {
+ $this->assertFalse($this->payload->matches([]));
+ }
+
+ /** @test */
+ public function itShouldNotMatchValues()
+ {
+ $values = $this->payload->toArray();
+ $values['sub'] = 'dummy_subject';
+
+ $this->assertFalse($this->payload->matches($values));
+ }
+
+ /** @test */
+ public function itShouldNotMatchStrictValues()
+ {
+ $values = $this->payload->toArray();
+ $values['sub'] = (string) $values['sub'];
+
+ $this->assertFalse($this->payload->matchesStrict($values));
+ $this->assertFalse($this->payload->matches($values, true));
+ }
+
+ /** @test */
+ public function itShouldNotMatchANonExistingClaim()
+ {
+ $values = ['foo' => 'bar'];
+
+ $this->assertFalse($this->payload->matches($values));
+ }
+
+ /**
+ * @return \HyperfExt\Jwt\Payload
+ */
+ private function getTestPayload(array $extraClaims = [])
+ {
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp + 3600),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp),
+ new JwtId('foo'),
+ ];
+
+ if ($extraClaims) {
+ $claims = array_merge($claims, $extraClaims);
+ }
+
+ $collection = Collection::make($claims);
+
+ return new Payload($collection);
+ }
+}
diff --git a/tests/RequestParserTest.php b/tests/RequestParserTest.php
new file mode 100644
index 0000000..5edf607
--- /dev/null
+++ b/tests/RequestParserTest.php
@@ -0,0 +1,357 @@
+ 'Bearer foobar',
+ ]));
+ $request = new HttpServerRequest();
+
+ $parser = new RequestParser();
+
+ $parser->setHandlers([
+ new QueryString(),
+ new InputSource(),
+ new AuthHeaders(),
+ new RouteParams(),
+ ]);
+
+ $this->assertSame($parser->parseToken($request), 'foobar');
+ $this->assertTrue($parser->hasToken($request));
+ }
+
+ /** @test */
+ public function itShouldReturnTheTokenFromThePrefixedAuthenticationHeader()
+ {
+ Context::set(ServerRequestInterface::class, new Request('POST', 'foo', [
+ 'Authorization' => 'Custom foobar',
+ ]));
+ $request = new HttpServerRequest();
+
+ $parser = new RequestParser();
+
+ $parser->setHandlers([
+ new QueryString(),
+ new InputSource(),
+ (new AuthHeaders())->setHeaderPrefix('Custom'),
+ new RouteParams(),
+ ]);
+
+ $this->assertSame($parser->parseToken($request), 'foobar');
+ $this->assertTrue($parser->hasToken($request));
+ }
+
+ /** @test */
+ public function itShouldReturnTheTokenFromTheCustomAuthenticationHeader()
+ {
+ Context::set(ServerRequestInterface::class, new Request('POST', 'foo', [
+ 'custom_authorization' => 'Bearer foobar',
+ ]));
+ $request = new HttpServerRequest();
+
+ $parser = new RequestParser();
+
+ $parser->setHandlers([
+ new QueryString(),
+ new InputSource(),
+ (new AuthHeaders())->setHeaderName('custom_authorization'),
+ new RouteParams(),
+ ]);
+
+ $this->assertSame($parser->parseToken($request), 'foobar');
+ $this->assertTrue($parser->hasToken($request));
+ }
+
+ /** @test */
+ public function itShouldReturnTheTokenFromQueryString()
+ {
+ Context::set(
+ ServerRequestInterface::class,
+ (new Request('GET', '/'))
+ ->withAttribute(Dispatched::class, new Dispatched([
+ Dispatcher::FOUND, null, ['token' => 'foobar'],
+ ]))
+ );
+ $request = new HttpServerRequest();
+
+ $parser = new RequestParser();
+ $parser->setHandlers([
+ new AuthHeaders(),
+ new QueryString(),
+ new InputSource(),
+ new RouteParams(),
+ ]);
+
+ $this->assertSame($parser->parseToken($request), 'foobar');
+ $this->assertTrue($parser->hasToken($request));
+ }
+
+ /** @test */
+ public function itShouldReturnTheTokenFromTheCustomQueryString()
+ {
+ Context::set(
+ ServerRequestInterface::class,
+ (new Request('GET', '/foo'))
+ ->withQueryParams(['custom_token_key' => 'foobar'])
+ ->withAttribute(Dispatched::class, new Dispatched([
+ Dispatcher::FOUND, null, ['custom_token_key' => 'foobar'],
+ ]))
+ );
+ $request = new HttpServerRequest();
+
+ $parser = new RequestParser();
+ $parser->setHandlers([
+ new AuthHeaders(),
+ (new QueryString())->setKey('custom_token_key'),
+ new InputSource(),
+ new RouteParams(),
+ ]);
+
+ $this->assertSame($parser->parseToken($request), 'foobar');
+ $this->assertTrue($parser->hasToken($request));
+ }
+
+ /** @test */
+ public function itShouldReturnTheTokenFromTheQueryStringNotTheInputSource()
+ {
+ Context::set(
+ ServerRequestInterface::class,
+ (new Request('POST', 'foo'))
+ ->withQueryParams(['token' => 'foobar'])
+ ->withParsedBody(['token' => 'foobarbaz'])
+ );
+ $request = new HttpServerRequest();
+
+ $parser = new RequestParser();
+ $parser->setHandlers([
+ new AuthHeaders(),
+ new QueryString(),
+ new InputSource(),
+ new RouteParams(),
+ ]);
+
+ $this->assertSame($parser->parseToken($request), 'foobar');
+ $this->assertTrue($parser->hasToken($request));
+ }
+
+ /** @test */
+ public function itShouldReturnTheTokenFromTheCustomQueryStringNotTheCustomInputSource()
+ {
+ Context::set(
+ ServerRequestInterface::class,
+ (new Request('POST', 'foo'))
+ ->withQueryParams(['custom_token_key' => 'foobar'])
+ ->withParsedBody(['custom_token_key' => 'foobarbaz'])
+ );
+ $request = new HttpServerRequest();
+
+ $parser = new RequestParser();
+ $parser->setHandlers([
+ new AuthHeaders(),
+ (new QueryString())->setKey('custom_token_key'),
+ (new InputSource())->setKey('custom_token_key'),
+ new RouteParams(),
+ ]);
+
+ $this->assertSame($parser->parseToken($request), 'foobar');
+ $this->assertTrue($parser->hasToken($request));
+ }
+
+ /** @test */
+ public function itShouldReturnTheTokenFromInputSource()
+ {
+ Context::set(
+ ServerRequestInterface::class,
+ (new Request('POST', 'foo'))
+ ->withParsedBody(['token' => 'foobar'])
+ );
+ $request = new HttpServerRequest();
+
+ $parser = new RequestParser();
+ $parser->setHandlers([
+ new AuthHeaders(),
+ new QueryString(),
+ new InputSource(),
+ new RouteParams(),
+ ]);
+
+ $this->assertSame($parser->parseToken($request), 'foobar');
+ $this->assertTrue($parser->hasToken($request));
+ }
+
+ /** @test */
+ public function itShouldReturnTheTokenFromTheCustomInputSource()
+ {
+ Context::set(
+ ServerRequestInterface::class,
+ (new Request('POST', 'foo'))
+ ->withParsedBody(['custom_token_key' => 'foobar'])
+ );
+ $request = new HttpServerRequest();
+
+ $parser = new RequestParser();
+ $parser->setHandlers([
+ new AuthHeaders(),
+ new QueryString(),
+ (new InputSource())->setKey('custom_token_key'),
+ new RouteParams(),
+ ]);
+
+ $this->assertSame($parser->parseToken($request), 'foobar');
+ $this->assertTrue($parser->hasToken($request));
+ }
+
+ /** @test */
+ public function itShouldReturnTheTokenFromAnUnencryptedCookie()
+ {
+ Context::set(ServerRequestInterface::class, (new Request('POST', 'foo'))->withCookieParams([
+ 'token' => 'foobar',
+ ]));
+ $request = new HttpServerRequest();
+
+ $parser = new RequestParser();
+ $parser->setHandlers([
+ new AuthHeaders(),
+ new QueryString(),
+ new InputSource(),
+ new RouteParams(),
+ new Cookies(),
+ ]);
+
+ $this->assertSame($parser->parseToken($request), 'foobar');
+ $this->assertTrue($parser->hasToken($request));
+ }
+
+ /** @test */
+ public function itShouldReturnTheTokenFromRoute()
+ {
+ Context::set(ServerRequestInterface::class, (new Request('GET', 'foo'))->withAttribute(Dispatched::class, new Dispatched([
+ Dispatcher::FOUND, null, ['token' => 'foobar'],
+ ])));
+ $request = new HttpServerRequest();
+
+ $parser = new RequestParser();
+ $parser->setHandlers([
+ new AuthHeaders(),
+ new QueryString(),
+ new InputSource(),
+ new RouteParams(),
+ ]);
+
+ $this->assertSame($parser->parseToken($request), 'foobar');
+ $this->assertTrue($parser->hasToken($request));
+ }
+
+ /** @test */
+ public function itShouldReturnTheTokenFromRouteWithACustomParam()
+ {
+ Context::set(ServerRequestInterface::class, (new Request('GET', 'foo'))->withAttribute(Dispatched::class, new Dispatched([
+ Dispatcher::FOUND, null, ['custom_route_param' => 'foobar'],
+ ])));
+ $request = new HttpServerRequest();
+
+ $parser = new RequestParser();
+ $parser->setHandlers([
+ new AuthHeaders(),
+ new QueryString(),
+ new InputSource(),
+ (new RouteParams())->setKey('custom_route_param'),
+ ]);
+
+ $this->assertSame($parser->parseToken($request), 'foobar');
+ $this->assertTrue($parser->hasToken($request));
+ }
+
+ /** @test */
+ public function itShouldIgnoreRoutelessRequests()
+ {
+ Context::set(ServerRequestInterface::class, (new Request('GET', 'foo'))->withAttribute(Dispatched::class, new Dispatched([
+ Dispatcher::FOUND, null, [],
+ ])));
+ $request = new HttpServerRequest();
+
+ $parser = new RequestParser();
+ $parser->setHandlers([
+ new AuthHeaders(),
+ new QueryString(),
+ new InputSource(),
+ new RouteParams(),
+ ]);
+
+ $this->assertNull($parser->parseToken($request));
+ $this->assertFalse($parser->hasToken($request));
+ }
+
+ /** @test */
+ public function itShouldReturnNullIfNoTokenInRequest()
+ {
+ Context::set(ServerRequestInterface::class, (new Request('GET', 'foo'))->withAttribute(Dispatched::class, new Dispatched([
+ Dispatcher::FOUND, null, [],
+ ])));
+ $request = new HttpServerRequest();
+
+ $parser = new RequestParser();
+ $parser->setHandlers([
+ new AuthHeaders(),
+ new QueryString(),
+ new InputSource(),
+ new RouteParams(),
+ ]);
+
+ $this->assertNull($parser->parseToken($request));
+ $this->assertFalse($parser->hasToken($request));
+ }
+
+ /** @test */
+ public function itShouldRetrieveTheHandlers()
+ {
+ $handlers = [
+ new AuthHeaders(),
+ new QueryString(),
+ new InputSource(),
+ new RouteParams(),
+ ];
+
+ $parser = new RequestParser();
+ $parser->setHandlers($handlers);
+
+ $this->assertSame($parser->getHandlers(), $handlers);
+ }
+
+ /** @test */
+ public function itShouldSetTheCookieKey()
+ {
+ $cookies = (new Cookies())->setKey('test');
+ $this->assertInstanceOf(Cookies::class, $cookies);
+ }
+}
diff --git a/tests/Storage/HyperfCacheTest.php b/tests/Storage/HyperfCacheTest.php
new file mode 100644
index 0000000..3455430
--- /dev/null
+++ b/tests/Storage/HyperfCacheTest.php
@@ -0,0 +1,121 @@
+cache = Mockery::mock(Cache::class);
+ $this->tag = 'jwt.default';
+ $this->storage = new HyperfCache($this->cache, $this->tag);
+ }
+
+ /** @test */
+ public function itShouldAddTheItemToStorage()
+ {
+ $this->cache->shouldReceive('set')->with($this->resolveKey('foo'), 'bar', 10)->once();
+
+ $this->storage->add('foo', 'bar', 10);
+ $this->assertTrue(true);
+ }
+
+ /** @test */
+ public function itShouldAddTheItemToStorageForever()
+ {
+ $this->cache->shouldReceive('set')->with($this->resolveKey('foo'), 'bar')->once();
+
+ $this->storage->forever('foo', 'bar');
+ $this->assertTrue(true);
+ }
+
+ /** @test */
+ public function itShouldGetAnItemFromStorage()
+ {
+ $this->cache->shouldReceive('get')->with($this->resolveKey('foo'))->once()->andReturn(['foo' => 'bar']);
+
+ $this->assertSame(['foo' => 'bar'], $this->storage->get('foo'));
+ }
+
+ /** @test */
+ public function itShouldRemoveTheItemFromStorage()
+ {
+ $this->cache->shouldReceive('delete')->with($this->resolveKey('foo'))->once()->andReturn(true);
+
+ $this->assertTrue($this->storage->destroy('foo'));
+ }
+
+ /** @test */
+ public function itShouldRemoveAllItemsFromStorage()
+ {
+ $this->cache->shouldReceive('clear')->withNoArgs()->once();
+
+ $this->storage->flush();
+ $this->assertTrue(true);
+ }
+
+ /** @test */
+ public function itShouldAddTheItemToTaggedStorage()
+ {
+ $this->cache->shouldReceive('set')->with($this->resolveKey('foo'), 'bar', 10)->once();
+
+ $this->storage->add('foo', 'bar', 10);
+ $this->assertTrue(true);
+ }
+
+ /** @test */
+ public function itShouldAddTheItemToTaggedStorageForever()
+ {
+ $this->cache->shouldReceive('set')->with($this->resolveKey('foo'), 'bar')->once();
+
+ $this->storage->forever('foo', 'bar');
+ $this->assertTrue(true);
+ }
+
+ /** @test */
+ public function itShouldGetAnItemFromTaggedStorage()
+ {
+ $this->cache->shouldReceive('get')->with($this->resolveKey('foo'))->once()->andReturn(['foo' => 'bar']);
+
+ $this->assertSame(['foo' => 'bar'], $this->storage->get('foo'));
+ }
+
+ protected function resolveKey(string $key)
+ {
+ return $this->tag . '.' . $key;
+ }
+}
diff --git a/tests/TokenTest.php b/tests/TokenTest.php
new file mode 100644
index 0000000..12bfebe
--- /dev/null
+++ b/tests/TokenTest.php
@@ -0,0 +1,44 @@
+token = new Token('foo.bar.baz');
+ }
+
+ /** @test */
+ public function itShouldReturnTheTokenWhenCastingToAString()
+ {
+ $this->assertEquals((string) $this->token, $this->token);
+ }
+
+ /** @test */
+ public function itShouldReturnTheTokenWhenCallingGetMethod()
+ {
+ $this->assertIsString($this->token->get());
+ }
+}
diff --git a/tests/Validators/PayloadValidatorTest.php b/tests/Validators/PayloadValidatorTest.php
new file mode 100644
index 0000000..4689248
--- /dev/null
+++ b/tests/Validators/PayloadValidatorTest.php
@@ -0,0 +1,235 @@
+validator = $this->container->get(PayloadValidatorInterface::class);
+ $this->validator->setRequiredClaims([
+ 'iss',
+ 'iat',
+ 'exp',
+ 'nbf',
+ 'sub',
+ 'jti',
+ ]);
+ }
+
+ /** @test */
+ public function itShouldReturnTrueWhenProvidingAValidPayload()
+ {
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp + 3600),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp),
+ new JwtId('foo'),
+ ];
+
+ $collection = Collection::make($claims);
+
+ $this->assertTrue($this->validator->isValid($collection));
+ }
+
+ /** @test */
+ public function itShouldThrowAnExceptionWhenProvidingAnExpiredPayload()
+ {
+ $this->expectExceptionMessage('Token has expired');
+ $this->expectException(TokenExpiredException::class);
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp - 1440),
+ new NotBefore($this->testNowTimestamp - 3660),
+ new IssuedAt($this->testNowTimestamp - 3660),
+ new JwtId('foo'),
+ ];
+
+ $collection = Collection::make($claims);
+
+ $this->validator->check($collection);
+ }
+
+ /** @test */
+ public function itShouldThrowAnExceptionWhenProvidingAnInvalidNbfClaim()
+ {
+ $this->expectExceptionMessage('Not Before (nbf) timestamp cannot be in the future');
+ $this->expectException(TokenInvalidException::class);
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp + 1440),
+ new NotBefore($this->testNowTimestamp + 3660),
+ new IssuedAt($this->testNowTimestamp - 3660),
+ new JwtId('foo'),
+ ];
+
+ $collection = Collection::make($claims);
+
+ $this->validator->check($collection);
+ }
+
+ /** @test */
+ public function itShouldThrowAnExceptionWhenProvidingAnInvalidIatClaim()
+ {
+ $this->expectExceptionMessage('Invalid value provided for claim [iat]');
+ $this->expectException(InvalidClaimException::class);
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp + 1440),
+ new NotBefore($this->testNowTimestamp - 3660),
+ new IssuedAt($this->testNowTimestamp + 3660),
+ new JwtId('foo'),
+ ];
+
+ $collection = Collection::make($claims);
+
+ $this->validator->check($collection);
+ }
+
+ /** @test */
+ public function itShouldThrowAnExceptionWhenProvidingAnInvalidPayload()
+ {
+ $this->expectExceptionMessage('JWT payload does not contain the required claims');
+ $this->expectException(TokenInvalidException::class);
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ ];
+
+ $collection = Collection::make($claims);
+
+ $this->validator->check($collection);
+ }
+
+ /** @test */
+ public function itShouldThrowAnExceptionWhenProvidingAnInvalidExpiry()
+ {
+ $this->expectExceptionMessage('Invalid value provided for claim [exp]');
+ $this->expectException(InvalidClaimException::class);
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration('foo'),
+ new NotBefore($this->testNowTimestamp - 3660),
+ new IssuedAt($this->testNowTimestamp + 3660),
+ new JwtId('foo'),
+ ];
+
+ $collection = Collection::make($claims);
+
+ $this->validator->check($collection);
+ }
+
+ /** @test */
+ public function itShouldSetTheRequiredClaims()
+ {
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ ];
+
+ $collection = Collection::make($claims);
+
+ $this->assertTrue($this->validator->setRequiredClaims(['iss', 'sub'])->isValid($collection));
+ }
+
+ /** @test */
+ public function itShouldCheckTheTokenInTheRefreshContext()
+ {
+ $this->claimFactory->setRefreshTtl(3600);
+
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp - 1000),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp - 2600), // this is LESS than the refresh ttl at 1 hour
+ new JwtId('foo'),
+ ];
+
+ $collection = Collection::make($claims);
+
+ $this->assertTrue(
+ $this->validator->isValid($collection, true)
+ );
+ }
+
+ /** @test */
+ public function itShouldReturnTrueIfTheRefreshTtlIsNull()
+ {
+ $this->claimFactory->setRefreshTtl(null);
+
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp - 1000),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp - 2600), // this is LESS than the refresh ttl at 1 hour
+ new JwtId('foo'),
+ ];
+
+ $collection = Collection::make($claims);
+
+ $this->assertTrue(
+ $this->validator->isValid($collection, true)
+ );
+ }
+
+ /** @test */
+ public function itShouldThrowAnExceptionIfTheTokenCannotBeRefreshed()
+ {
+ $this->expectExceptionMessage('Token has expired and can no longer be refreshed');
+ $this->expectException(TokenExpiredException::class);
+ $this->claimFactory->setRefreshTtl(3600);
+
+ $claims = [
+ new Subject(1),
+ new Issuer('http://example.com'),
+ new Expiration($this->testNowTimestamp),
+ new NotBefore($this->testNowTimestamp),
+ new IssuedAt($this->testNowTimestamp - 5000), // this is MORE than the refresh ttl at 1 hour, so is invalid
+ new JwtId('foo'),
+ ];
+
+ $collection = Collection::make($claims);
+
+ $this->validator->check($collection, true);
+ }
+}
diff --git a/tests/Validators/TokenValidatorTest.php b/tests/Validators/TokenValidatorTest.php
new file mode 100644
index 0000000..44c1099
--- /dev/null
+++ b/tests/Validators/TokenValidatorTest.php
@@ -0,0 +1,110 @@
+validator = $this->container->get(TokenValidatorInterface::class);
+ }
+
+ /** @test */
+ public function itShouldReturnTrueWhenProvidingAWellFormedToken()
+ {
+ $this->assertTrue($this->validator->isValid('one.two.three'));
+ }
+
+ public function dataProviderMalformedTokens()
+ {
+ return [
+ ['one.two.'],
+ ['.two.'],
+ ['.two.three'],
+ ['one..three'],
+ ['..'],
+ [' . . '],
+ [' one . two . three '],
+ ];
+ }
+
+ /**
+ * @test
+ * @dataProvider \HyperfTest\Validators\TokenValidatorTest::dataProviderMalformedTokens
+ *
+ * @param string $token
+ */
+ public function itShouldReturnFalseWhenProvidingAMalformedToken($token)
+ {
+ $this->assertFalse($this->validator->isValid($token));
+ }
+
+ /**
+ * @test
+ * @dataProvider \HyperfTest\Validators\TokenValidatorTest::dataProviderMalformedTokens
+ *
+ * @param string $token
+ */
+ public function itShouldThrowAnExceptionWhenProvidingAMalformedToken($token)
+ {
+ $this->expectExceptionMessage('Malformed token');
+ $this->expectException(TokenInvalidException::class);
+ $this->validator->check($token);
+ }
+
+ public function dataProviderTokensWithWrongSegmentsNumber()
+ {
+ return [
+ ['one.two'],
+ ['one.two.three.four'],
+ ['one.two.three.four.five'],
+ ];
+ }
+
+ /**
+ * @test
+ * @dataProvider \HyperfTest\Validators\TokenValidatorTest::dataProviderTokensWithWrongSegmentsNumber
+ *
+ * @param string $token
+ */
+ public function itShouldReturnFalseWhenProvidingATokenWithWrongSegmentsNumber($token)
+ {
+ $this->assertFalse($this->validator->isValid($token));
+ }
+
+ /**
+ * @test
+ * @dataProvider \HyperfTest\Validators\TokenValidatorTest::dataProviderTokensWithWrongSegmentsNumber
+ *
+ * @param string $token
+ */
+ public function itShouldThrowAnExceptionWhenProvidingAMalformedTokenWithWrongSegmentsNumber($token)
+ {
+ $this->expectExceptionMessage('Wrong number of segments');
+ $this->expectException(TokenInvalidException::class);
+ $this->validator->check($token);
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..3b87b8c
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,48 @@
+set(ConfigInterface::class, $config = new Config([]));
+
+$container->set(PayloadValidatorInterface::class, new PayloadValidator($config));
+$container->set(TokenValidatorInterface::class, new TokenValidator());
+$container->set(ServerRequestInterface::class, new HttpServerRequest());
+Context::set(ServerRequestInterface::class, new Request('GET', '/'));
+
+ApplicationContext::setContainer($container);
+
+$container->get(Hyperf\Contract\ApplicationInterface::class);