diff --git a/.gitattributes b/.gitattributes index 6d63e560..d5d535d9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,9 @@ * text=auto -/tests export-ignore /.gitattributes export-ignore /.gitignore export-ignore -/.travis.yml export-ignore -/phpunit.xml.dist export-ignore /.github export-ignore +/.php-cs-fixer.dist.php export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore diff --git a/.github/actions/entrypoint.sh b/.github/actions/entrypoint.sh deleted file mode 100755 index 40402bc8..00000000 --- a/.github/actions/entrypoint.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/sh -l - -apt-get update && \ -apt-get install -y --no-install-recommends \ - git \ - zip \ - curl \ - ca-certificates \ - unzip \ - wget - -curl --silent --show-error https://getcomposer.org/installer | php -php composer.phar self-update - -echo "---Installing dependencies ---" - -# Add compatiblity for libsodium with older versions of PHP -php composer.phar require --dev --with-dependencies paragonie/sodium_compat - -echo "---Running unit tests ---" -vendor/bin/phpunit diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 00000000..409df545 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,19 @@ +name: release-please +on: + push: + branches: + - main +permissions: + # Needed for Release Please to create and update files + contents: write + # Needed for Release Please to create Release PRs + pull-requests: write +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + id: release + with: + token: ${{ secrets.YOSHI_CODE_BOT_TOKEN }} + release-type: simple diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 68d4f10b..6e0cd3c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ "7.1", "7.2", "7.3", "7.4", "8.0", "8.1"] + php: [ "8.0", "8.1", "8.2", "8.3", "8.4" ] name: PHP ${{matrix.php }} Unit Test steps: - uses: actions/checkout@v2 @@ -35,12 +35,11 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: "8.0" + php-version: "8.3" - name: Run Script run: | - composer require friendsofphp/php-cs-fixer - vendor/bin/php-cs-fixer fix --diff --dry-run . - vendor/bin/php-cs-fixer fix --rules=native_function_invocation --allow-risky=yes --diff src + composer global require friendsofphp/php-cs-fixer + ~/.composer/vendor/bin/php-cs-fixer fix --diff --dry-run --allow-risky=yes . staticanalysis: runs-on: ubuntu-latest @@ -50,8 +49,9 @@ jobs: - name: Install PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.0' + php-version: '8.3' - name: Run Script run: | - composer global require phpstan/phpstan + composer install + composer global require phpstan/phpstan:~1.10.0 ~/.composer/vendor/bin/phpstan analyse diff --git a/.gitignore b/.gitignore index b22842cb..f720fb76 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ phpunit.phar.asc composer.phar composer.lock .phpunit.result.cache +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 00000000..93ff7a4c --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,28 @@ +setRules([ + '@PSR2' => true, + 'concat_space' => ['spacing' => 'one'], + 'no_unused_imports' => true, + 'ordered_imports' => true, + 'new_with_braces' => true, + 'method_argument_space' => false, + 'whitespace_after_comma_in_array' => true, + 'return_type_declaration' => [ + 'space_before' => 'none' + ], + 'single_quote' => true, + 'native_function_invocation' => [ + 'strict' => false + ], + 'nullable_type_declaration' => [ + 'syntax' => 'question_mark', + ], + 'nullable_type_declaration_for_default_null_value' => true, + ]) + ->setFinder( + PhpCsFixer\Finder::create() + ->in(__DIR__) + ) +; diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..26376076 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,229 @@ +# Changelog + +## [7.0.2](https://github.com/firebase/php-jwt/compare/v7.0.1...v7.0.2) (2025-12-16) + + +### Bug Fixes + +* add key length validation for ec keys ([#615](https://github.com/firebase/php-jwt/issues/615)) ([7044f9a](https://github.com/firebase/php-jwt/commit/7044f9ae7e7d175d28cca71714feb236f1c0e252)) + +## [7.0.0](https://github.com/firebase/php-jwt/compare/v6.11.1...v7.0.0) (2025-12-15) + + +### ⚠️ ⚠️ ⚠️ Security Fixes ⚠️ ⚠️ ⚠️ + * add key size validation ([#613](https://github.com/firebase/php-jwt/issues/613)) ([6b80341](https://github.com/firebase/php-jwt/commit/6b80341bf57838ea2d011487917337901cd71576)) + **NOTE**: This fix will cause keys with a size below the minimally allowed size to break. + +### Features + +* add SensitiveParameter attribute to security-critical parameters ([#603](https://github.com/firebase/php-jwt/issues/603)) ([4dbfac0](https://github.com/firebase/php-jwt/commit/4dbfac0260eeb0e9e643063c99998e3219cc539b)) +* store timestamp in `ExpiredException` ([#604](https://github.com/firebase/php-jwt/issues/604)) ([f174826](https://github.com/firebase/php-jwt/commit/f1748260d218a856b6a0c23715ac7fae1d7ca95b)) + + +### Bug Fixes + +* validate iat and nbf on payload ([#568](https://github.com/firebase/php-jwt/issues/568)) ([953b2c8](https://github.com/firebase/php-jwt/commit/953b2c88bb445b7e3bb82a5141928f13d7343afd)) + +## [6.11.1](https://github.com/firebase/php-jwt/compare/v6.11.0...v6.11.1) (2025-04-09) + + +### Bug Fixes + +* update error text for consistency ([#528](https://github.com/firebase/php-jwt/issues/528)) ([c11113a](https://github.com/firebase/php-jwt/commit/c11113afa13265e016a669e75494b9203b8a7775)) + +## [6.11.0](https://github.com/firebase/php-jwt/compare/v6.10.2...v6.11.0) (2025-01-23) + + +### Features + +* support octet typed JWK ([#587](https://github.com/firebase/php-jwt/issues/587)) ([7cb8a26](https://github.com/firebase/php-jwt/commit/7cb8a265fa81edf2fa6ef8098f5bc5ae573c33ad)) + + +### Bug Fixes + +* refactor constructor Key to use PHP 8.0 syntax ([#577](https://github.com/firebase/php-jwt/issues/577)) ([29fa2ce](https://github.com/firebase/php-jwt/commit/29fa2ce9e0582cd397711eec1e80c05ce20fabca)) + +## [6.10.2](https://github.com/firebase/php-jwt/compare/v6.10.1...v6.10.2) (2024-11-24) + + +### Bug Fixes + +* Mitigate PHP8.4 deprecation warnings ([#570](https://github.com/firebase/php-jwt/issues/570)) ([76808fa](https://github.com/firebase/php-jwt/commit/76808fa227f3811aa5cdb3bf81233714b799a5b5)) +* support php 8.4 ([#583](https://github.com/firebase/php-jwt/issues/583)) ([e3d68b0](https://github.com/firebase/php-jwt/commit/e3d68b044421339443c74199edd020e03fb1887e)) + +## [6.10.1](https://github.com/firebase/php-jwt/compare/v6.10.0...v6.10.1) (2024-05-18) + + +### Bug Fixes + +* ensure ratelimit expiry is set every time ([#556](https://github.com/firebase/php-jwt/issues/556)) ([09cb208](https://github.com/firebase/php-jwt/commit/09cb2081c2c3bc0f61e2f2a5fbea5741f7498648)) +* ratelimit cache expiration ([#550](https://github.com/firebase/php-jwt/issues/550)) ([dda7250](https://github.com/firebase/php-jwt/commit/dda725033585ece30ff8cae8937320d7e9f18bae)) + +## [6.10.0](https://github.com/firebase/php-jwt/compare/v6.9.0...v6.10.0) (2023-11-28) + + +### Features + +* allow typ header override ([#546](https://github.com/firebase/php-jwt/issues/546)) ([79cb30b](https://github.com/firebase/php-jwt/commit/79cb30b729a22931b2fbd6b53f20629a83031ba9)) + +## [6.9.0](https://github.com/firebase/php-jwt/compare/v6.8.1...v6.9.0) (2023-10-04) + + +### Features + +* add payload to jwt exception ([#521](https://github.com/firebase/php-jwt/issues/521)) ([175edf9](https://github.com/firebase/php-jwt/commit/175edf958bb61922ec135b2333acf5622f2238a2)) + +## [6.8.1](https://github.com/firebase/php-jwt/compare/v6.8.0...v6.8.1) (2023-07-14) + + +### Bug Fixes + +* accept float claims but round down to ignore them ([#492](https://github.com/firebase/php-jwt/issues/492)) ([3936842](https://github.com/firebase/php-jwt/commit/39368423beeaacb3002afa7dcb75baebf204fe7e)) +* different BeforeValidException messages for nbf and iat ([#526](https://github.com/firebase/php-jwt/issues/526)) ([0a53cf2](https://github.com/firebase/php-jwt/commit/0a53cf2986e45c2bcbf1a269f313ebf56a154ee4)) + +## [6.8.0](https://github.com/firebase/php-jwt/compare/v6.7.0...v6.8.0) (2023-06-14) + + +### Features + +* add support for P-384 curve ([#515](https://github.com/firebase/php-jwt/issues/515)) ([5de4323](https://github.com/firebase/php-jwt/commit/5de4323f4baf4d70bca8663bd87682a69c656c3d)) + + +### Bug Fixes + +* handle invalid http responses ([#508](https://github.com/firebase/php-jwt/issues/508)) ([91c39c7](https://github.com/firebase/php-jwt/commit/91c39c72b22fc3e1191e574089552c1f2041c718)) + +## [6.7.0](https://github.com/firebase/php-jwt/compare/v6.6.0...v6.7.0) (2023-06-14) + + +### Features + +* add ed25519 support to JWK (public keys) ([#452](https://github.com/firebase/php-jwt/issues/452)) ([e53979a](https://github.com/firebase/php-jwt/commit/e53979abae927de916a75b9d239cfda8ce32be2a)) + +## [6.6.0](https://github.com/firebase/php-jwt/compare/v6.5.0...v6.6.0) (2023-06-13) + + +### Features + +* allow get headers when decoding token ([#442](https://github.com/firebase/php-jwt/issues/442)) ([fb85f47](https://github.com/firebase/php-jwt/commit/fb85f47cfaeffdd94faf8defdf07164abcdad6c3)) + + +### Bug Fixes + +* only check iat if nbf is not used ([#493](https://github.com/firebase/php-jwt/issues/493)) ([398ccd2](https://github.com/firebase/php-jwt/commit/398ccd25ea12fa84b9e4f1085d5ff448c21ec797)) + +## [6.5.0](https://github.com/firebase/php-jwt/compare/v6.4.0...v6.5.0) (2023-05-12) + + +### Bug Fixes + +* allow KID of '0' ([#505](https://github.com/firebase/php-jwt/issues/505)) ([9dc46a9](https://github.com/firebase/php-jwt/commit/9dc46a9c3e5801294249cfd2554c5363c9f9326a)) + + +### Miscellaneous Chores + +* drop support for PHP 7.3 ([#495](https://github.com/firebase/php-jwt/issues/495)) + +## [6.4.0](https://github.com/firebase/php-jwt/compare/v6.3.2...v6.4.0) (2023-02-08) + + +### Features + +* add support for W3C ES256K ([#462](https://github.com/firebase/php-jwt/issues/462)) ([213924f](https://github.com/firebase/php-jwt/commit/213924f51936291fbbca99158b11bd4ae56c2c95)) +* improve caching by only decoding jwks when necessary ([#486](https://github.com/firebase/php-jwt/issues/486)) ([78d3ed1](https://github.com/firebase/php-jwt/commit/78d3ed1073553f7d0bbffa6c2010009a0d483d5c)) + +## [6.3.2](https://github.com/firebase/php-jwt/compare/v6.3.1...v6.3.2) (2022-11-01) + + +### Bug Fixes + +* check kid before using as array index ([bad1b04](https://github.com/firebase/php-jwt/commit/bad1b040d0c736bbf86814c6b5ae614f517cf7bd)) + +## [6.3.1](https://github.com/firebase/php-jwt/compare/v6.3.0...v6.3.1) (2022-11-01) + + +### Bug Fixes + +* casing of GET for PSR compat ([#451](https://github.com/firebase/php-jwt/issues/451)) ([60b52b7](https://github.com/firebase/php-jwt/commit/60b52b71978790eafcf3b95cfbd83db0439e8d22)) +* string interpolation format for php 8.2 ([#446](https://github.com/firebase/php-jwt/issues/446)) ([2e07d8a](https://github.com/firebase/php-jwt/commit/2e07d8a1524d12b69b110ad649f17461d068b8f2)) + +## 6.3.0 / 2022-07-15 + + - Added ES256 support to JWK parsing ([#399](https://github.com/firebase/php-jwt/pull/399)) + - Fixed potential caching error in `CachedKeySet` by caching jwks as strings ([#435](https://github.com/firebase/php-jwt/pull/435)) + +## 6.2.0 / 2022-05-14 + + - Added `CachedKeySet` ([#397](https://github.com/firebase/php-jwt/pull/397)) + - Added `$defaultAlg` parameter to `JWT::parseKey` and `JWT::parseKeySet` ([#426](https://github.com/firebase/php-jwt/pull/426)). + +## 6.1.0 / 2022-03-23 + + - Drop support for PHP 5.3, 5.4, 5.5, 5.6, and 7.0 + - Add parameter typing and return types where possible + +## 6.0.0 / 2022-01-24 + + - **Backwards-Compatibility Breaking Changes**: See the [Release Notes](https://github.com/firebase/php-jwt/releases/tag/v6.0.0) for more information. + - New Key object to prevent key/algorithm type confusion (#365) + - Add JWK support (#273) + - Add ES256 support (#256) + - Add ES384 support (#324) + - Add Ed25519 support (#343) + +## 5.0.0 / 2017-06-26 +- Support RS384 and RS512. + See [#117](https://github.com/firebase/php-jwt/pull/117). Thanks [@joostfaassen](https://github.com/joostfaassen)! +- Add an example for RS256 openssl. + See [#125](https://github.com/firebase/php-jwt/pull/125). Thanks [@akeeman](https://github.com/akeeman)! +- Detect invalid Base64 encoding in signature. + See [#162](https://github.com/firebase/php-jwt/pull/162). Thanks [@psignoret](https://github.com/psignoret)! +- Update `JWT::verify` to handle OpenSSL errors. + See [#159](https://github.com/firebase/php-jwt/pull/159). Thanks [@bshaffer](https://github.com/bshaffer)! +- Add `array` type hinting to `decode` method + See [#101](https://github.com/firebase/php-jwt/pull/101). Thanks [@hywak](https://github.com/hywak)! +- Add all JSON error types. + See [#110](https://github.com/firebase/php-jwt/pull/110). Thanks [@gbalduzzi](https://github.com/gbalduzzi)! +- Bugfix 'kid' not in given key list. + See [#129](https://github.com/firebase/php-jwt/pull/129). Thanks [@stampycode](https://github.com/stampycode)! +- Miscellaneous cleanup, documentation and test fixes. + See [#107](https://github.com/firebase/php-jwt/pull/107), [#115](https://github.com/firebase/php-jwt/pull/115), + [#160](https://github.com/firebase/php-jwt/pull/160), [#161](https://github.com/firebase/php-jwt/pull/161), and + [#165](https://github.com/firebase/php-jwt/pull/165). Thanks [@akeeman](https://github.com/akeeman), + [@chinedufn](https://github.com/chinedufn), and [@bshaffer](https://github.com/bshaffer)! + +## 4.0.0 / 2016-07-17 +- Add support for late static binding. See [#88](https://github.com/firebase/php-jwt/pull/88) for details. Thanks to [@chappy84](https://github.com/chappy84)! +- Use static `$timestamp` instead of `time()` to improve unit testing. See [#93](https://github.com/firebase/php-jwt/pull/93) for details. Thanks to [@josephmcdermott](https://github.com/josephmcdermott)! +- Fixes to exceptions classes. See [#81](https://github.com/firebase/php-jwt/pull/81) for details. Thanks to [@Maks3w](https://github.com/Maks3w)! +- Fixes to PHPDoc. See [#76](https://github.com/firebase/php-jwt/pull/76) for details. Thanks to [@akeeman](https://github.com/akeeman)! + +## 3.0.0 / 2015-07-22 +- Minimum PHP version updated from `5.2.0` to `5.3.0`. +- Add `\Firebase\JWT` namespace. See +[#59](https://github.com/firebase/php-jwt/pull/59) for details. Thanks to +[@Dashron](https://github.com/Dashron)! +- Require a non-empty key to decode and verify a JWT. See +[#60](https://github.com/firebase/php-jwt/pull/60) for details. Thanks to +[@sjones608](https://github.com/sjones608)! +- Cleaner documentation blocks in the code. See +[#62](https://github.com/firebase/php-jwt/pull/62) for details. Thanks to +[@johanderuijter](https://github.com/johanderuijter)! + +## 2.2.0 / 2015-06-22 +- Add support for adding custom, optional JWT headers to `JWT::encode()`. See +[#53](https://github.com/firebase/php-jwt/pull/53/files) for details. Thanks to +[@mcocaro](https://github.com/mcocaro)! + +## 2.1.0 / 2015-05-20 +- Add support for adding a leeway to `JWT:decode()` that accounts for clock skew +between signing and verifying entities. Thanks to [@lcabral](https://github.com/lcabral)! +- Add support for passing an object implementing the `ArrayAccess` interface for +`$keys` argument in `JWT::decode()`. Thanks to [@aztech-dev](https://github.com/aztech-dev)! + +## 2.0.0 / 2015-04-01 +- **Note**: It is strongly recommended that you update to > v2.0.0 to address + known security vulnerabilities in prior versions when both symmetric and + asymmetric keys are used together. +- Update signature for `JWT::decode(...)` to require an array of supported + algorithms to use when verifying token signatures. diff --git a/README.md b/README.md index d489c69f..65b6c860 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ composer require firebase/php-jwt ``` Optionally, install the `paragonie/sodium_compat` package from composer if your -php is < 7.2 or does not have libsodium installed: +php env does not have libsodium installed: ```bash composer require paragonie/sodium_compat @@ -29,13 +29,13 @@ Example use Firebase\JWT\JWT; use Firebase\JWT\Key; -$key = "example_key"; -$payload = array( - "iss" => "http://example.org/", - "aud" => "http://example.com/", - "iat" => 1356999524, - "nbf" => 1357000000 -); +$key = 'example_key'; +$payload = [ + 'iss' => 'http://example.org/', + 'aud' => 'http://example.com/', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; /** * IMPORTANT: @@ -45,9 +45,13 @@ $payload = array( */ $jwt = JWT::encode($payload, $key, 'HS256'); $decoded = JWT::decode($jwt, new Key($key, 'HS256')); - print_r($decoded); +// Pass a stdClass in as the third parameter to get the decoded header values +$headers = new stdClass(); +$decoded = JWT::decode($jwt, new Key($key, 'HS256'), $headers); +print_r($headers); + /* NOTE: This will now be an object instead of an associative array. To get an associative array, you will need to cast it as such: @@ -65,6 +69,40 @@ $decoded_array = (array) $decoded; JWT::$leeway = 60; // $leeway in seconds $decoded = JWT::decode($jwt, new Key($key, 'HS256')); ``` +Example encode/decode headers +------- +Decoding the JWT headers without verifying the JWT first is NOT recommended, and is not supported by +this library. This is because without verifying the JWT, the header values could have been tampered with. +Any value pulled from an unverified header should be treated as if it could be any string sent in from an +attacker. If this is something you still want to do in your application for whatever reason, it's possible to +decode the header values manually simply by calling `json_decode` and `base64_decode` on the JWT +header part: +```php +use Firebase\JWT\JWT; + +$key = 'example_key'; +$payload = [ + 'iss' => 'http://example.org/', + 'aud' => 'http://example.com/', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +$headers = [ + 'x-forwarded-for' => 'www.google.com' +]; + +// Encode headers in the JWT string +$jwt = JWT::encode($payload, $key, 'HS256', null, $headers); + +// Decode headers from the JWT string WITHOUT validation +// **IMPORTANT**: This operation is vulnerable to attacks, as the JWT has not yet been verified. +// These headers could be any value sent by an attacker. +list($headersB64, $payloadB64, $sig) = explode('.', $jwt); +$decoded = json_decode(base64_decode($headersB64), true); + +print_r($decoded); +``` Example with RS256 (openssl) ---------------------------- ```php @@ -73,37 +111,52 @@ use Firebase\JWT\Key; $privateKey = << "example.org", - "aud" => "example.com", - "iat" => 1356999524, - "nbf" => 1357000000 -); +$payload = [ + 'iss' => 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; $jwt = JWT::encode($payload, $privateKey, 'RS256'); echo "Encode:\n" . print_r($jwt, true) . "\n"; @@ -133,18 +186,18 @@ $passphrase = '[YOUR_PASSPHRASE]'; // Can be generated with "ssh-keygen -t rsa -m pem" $privateKeyFile = '/path/to/key-with-passphrase.pem'; -// Create a private key of type "resource" +/** @var OpenSSLAsymmetricKey $privateKey */ $privateKey = openssl_pkey_get_private( file_get_contents($privateKeyFile), $passphrase ); -$payload = array( - "iss" => "example.org", - "aud" => "example.com", - "iat" => 1356999524, - "nbf" => 1357000000 -); +$payload = [ + 'iss' => 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; $jwt = JWT::encode($payload, $privateKey, 'RS256'); echo "Encode:\n" . print_r($jwt, true) . "\n"; @@ -173,12 +226,12 @@ $privateKey = base64_encode(sodium_crypto_sign_secretkey($keyPair)); $publicKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); -$payload = array( - "iss" => "example.org", - "aud" => "example.com", - "iat" => 1356999524, - "nbf" => 1357000000 -); +$payload = [ + 'iss' => 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; $jwt = JWT::encode($payload, $privateKey, 'EdDSA'); echo "Encode:\n" . print_r($jwt, true) . "\n"; @@ -187,6 +240,44 @@ $decoded = JWT::decode($jwt, new Key($publicKey, 'EdDSA')); echo "Decode:\n" . print_r((array) $decoded, true) . "\n"; ```` +Example with multiple keys +-------------------------- +```php +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + +// Example RSA keys from previous example +// $privateKey1 = '...'; +// $publicKey1 = '...'; + +// Example EdDSA keys from previous example +// $privateKey2 = '...'; +// $publicKey2 = '...'; + +$payload = [ + 'iss' => 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +$jwt1 = JWT::encode($payload, $privateKey1, 'RS256', 'kid1'); +$jwt2 = JWT::encode($payload, $privateKey2, 'EdDSA', 'kid2'); +echo "Encode 1:\n" . print_r($jwt1, true) . "\n"; +echo "Encode 2:\n" . print_r($jwt2, true) . "\n"; + +$keys = [ + 'kid1' => new Key($publicKey1, 'RS256'), + 'kid2' => new Key($publicKey2, 'EdDSA'), +]; + +$decoded1 = JWT::decode($jwt1, $keys); +$decoded2 = JWT::decode($jwt2, $keys); + +echo "Decode 1:\n" . print_r((array) $decoded1, true) . "\n"; +echo "Decode 2:\n" . print_r((array) $decoded2, true) . "\n"; +``` + Using JWKs ---------- @@ -200,12 +291,103 @@ $jwks = ['keys' => []]; // JWK::parseKeySet($jwks) returns an associative array of **kid** to Firebase\JWT\Key // objects. Pass this as the second parameter to JWT::decode. -JWT::decode($payload, JWK::parseKeySet($jwks)); +JWT::decode($jwt, JWK::parseKeySet($jwks)); +``` + +Using Cached Key Sets +--------------------- + +The `CachedKeySet` class can be used to fetch and cache JWKS (JSON Web Key Sets) from a public URI. +This has the following advantages: + +1. The results are cached for performance. +2. If an unrecognized key is requested, the cache is refreshed, to accomodate for key rotation. +3. If rate limiting is enabled, the JWKS URI will not make more than 10 requests a second. + +```php +use Firebase\JWT\CachedKeySet; +use Firebase\JWT\JWT; + +// The URI for the JWKS you wish to cache the results from +$jwksUri = 'https://www.gstatic.com/iap/verify/public_key-jwk'; + +// Create an HTTP client (can be any PSR-7 compatible HTTP client) +$httpClient = new GuzzleHttp\Client(); + +// Create an HTTP request factory (can be any PSR-17 compatible HTTP request factory) +$httpFactory = new GuzzleHttp\Psr\HttpFactory(); + +// Create a cache item pool (can be any PSR-6 compatible cache item pool) +$cacheItemPool = Phpfastcache\CacheManager::getInstance('files'); + +$keySet = new CachedKeySet( + $jwksUri, + $httpClient, + $httpFactory, + $cacheItemPool, + null, // $expiresAfter int seconds to set the JWKS to expire + true // $rateLimit true to enable rate limit of 10 RPS on lookup of invalid keys +); + +$jwt = 'eyJhbGci...'; // Some JWT signed by a key from the $jwkUri above +$decoded = JWT::decode($jwt, $keySet); ``` Miscellaneous ------------- +#### Exception Handling + +When a call to `JWT::decode` is invalid, it will throw one of the following exceptions: + +```php +use Firebase\JWT\JWT; +use Firebase\JWT\SignatureInvalidException; +use Firebase\JWT\BeforeValidException; +use Firebase\JWT\ExpiredException; +use DomainException; +use InvalidArgumentException; +use UnexpectedValueException; + +try { + $decoded = JWT::decode($jwt, $keys); +} catch (InvalidArgumentException $e) { + // provided key/key-array is empty or malformed. +} catch (DomainException $e) { + // provided algorithm is unsupported OR + // provided key is invalid OR + // unknown error thrown in openSSL or libsodium OR + // libsodium is required but not available. +} catch (SignatureInvalidException $e) { + // provided JWT signature verification failed. +} catch (BeforeValidException $e) { + // provided JWT is trying to be used before "nbf" claim OR + // provided JWT is trying to be used before "iat" claim. +} catch (ExpiredException $e) { + // provided JWT is trying to be used after "exp" claim. +} catch (UnexpectedValueException $e) { + // provided JWT is malformed OR + // provided JWT is missing an algorithm / using an unsupported algorithm OR + // provided JWT algorithm does not match provided key OR + // provided key ID in key/key-array is empty or invalid. +} +``` + +All exceptions in the `Firebase\JWT` namespace extend `UnexpectedValueException`, and can be simplified +like this: + +```php +use Firebase\JWT\JWT; +use UnexpectedValueException; +try { + $decoded = JWT::decode($jwt, $keys); +} catch (LogicException $e) { + // errors having to do with environmental setup or malformed JWT Keys +} catch (UnexpectedValueException $e) { + // errors having to do with JWT signature and claims +} +``` + #### Casting to array The return value of `JWT::decode` is the generic PHP object `stdClass`. If you'd like to handle with arrays @@ -213,82 +395,12 @@ instead, you can do the following: ```php // return type is stdClass -$decoded = JWT::decode($payload, $keys); +$decoded = JWT::decode($jwt, $keys); // cast to array $decoded = json_decode(json_encode($decoded), true); ``` -Changelog ---------- - -#### 6.0.0 / 2022-01-24 - - - **Backwards-Compatibility Breaking Changes**: See the [Release Notes](https://github.com/firebase/php-jwt/releases/tag/v6.0.0) for more information. - - New Key object to prevent key/algorithm type confusion (#365) - - Add JWK support (#273) - - Add ES256 support (#256) - - Add ES384 support (#324) - - Add Ed25519 support (#343) - -#### 5.0.0 / 2017-06-26 -- Support RS384 and RS512. - See [#117](https://github.com/firebase/php-jwt/pull/117). Thanks [@joostfaassen](https://github.com/joostfaassen)! -- Add an example for RS256 openssl. - See [#125](https://github.com/firebase/php-jwt/pull/125). Thanks [@akeeman](https://github.com/akeeman)! -- Detect invalid Base64 encoding in signature. - See [#162](https://github.com/firebase/php-jwt/pull/162). Thanks [@psignoret](https://github.com/psignoret)! -- Update `JWT::verify` to handle OpenSSL errors. - See [#159](https://github.com/firebase/php-jwt/pull/159). Thanks [@bshaffer](https://github.com/bshaffer)! -- Add `array` type hinting to `decode` method - See [#101](https://github.com/firebase/php-jwt/pull/101). Thanks [@hywak](https://github.com/hywak)! -- Add all JSON error types. - See [#110](https://github.com/firebase/php-jwt/pull/110). Thanks [@gbalduzzi](https://github.com/gbalduzzi)! -- Bugfix 'kid' not in given key list. - See [#129](https://github.com/firebase/php-jwt/pull/129). Thanks [@stampycode](https://github.com/stampycode)! -- Miscellaneous cleanup, documentation and test fixes. - See [#107](https://github.com/firebase/php-jwt/pull/107), [#115](https://github.com/firebase/php-jwt/pull/115), - [#160](https://github.com/firebase/php-jwt/pull/160), [#161](https://github.com/firebase/php-jwt/pull/161), and - [#165](https://github.com/firebase/php-jwt/pull/165). Thanks [@akeeman](https://github.com/akeeman), - [@chinedufn](https://github.com/chinedufn), and [@bshaffer](https://github.com/bshaffer)! - -#### 4.0.0 / 2016-07-17 -- Add support for late static binding. See [#88](https://github.com/firebase/php-jwt/pull/88) for details. Thanks to [@chappy84](https://github.com/chappy84)! -- Use static `$timestamp` instead of `time()` to improve unit testing. See [#93](https://github.com/firebase/php-jwt/pull/93) for details. Thanks to [@josephmcdermott](https://github.com/josephmcdermott)! -- Fixes to exceptions classes. See [#81](https://github.com/firebase/php-jwt/pull/81) for details. Thanks to [@Maks3w](https://github.com/Maks3w)! -- Fixes to PHPDoc. See [#76](https://github.com/firebase/php-jwt/pull/76) for details. Thanks to [@akeeman](https://github.com/akeeman)! - -#### 3.0.0 / 2015-07-22 -- Minimum PHP version updated from `5.2.0` to `5.3.0`. -- Add `\Firebase\JWT` namespace. See -[#59](https://github.com/firebase/php-jwt/pull/59) for details. Thanks to -[@Dashron](https://github.com/Dashron)! -- Require a non-empty key to decode and verify a JWT. See -[#60](https://github.com/firebase/php-jwt/pull/60) for details. Thanks to -[@sjones608](https://github.com/sjones608)! -- Cleaner documentation blocks in the code. See -[#62](https://github.com/firebase/php-jwt/pull/62) for details. Thanks to -[@johanderuijter](https://github.com/johanderuijter)! - -#### 2.2.0 / 2015-06-22 -- Add support for adding custom, optional JWT headers to `JWT::encode()`. See -[#53](https://github.com/firebase/php-jwt/pull/53/files) for details. Thanks to -[@mcocaro](https://github.com/mcocaro)! - -#### 2.1.0 / 2015-05-20 -- Add support for adding a leeway to `JWT:decode()` that accounts for clock skew -between signing and verifying entities. Thanks to [@lcabral](https://github.com/lcabral)! -- Add support for passing an object implementing the `ArrayAccess` interface for -`$keys` argument in `JWT::decode()`. Thanks to [@aztech-dev](https://github.com/aztech-dev)! - -#### 2.0.0 / 2015-04-01 -- **Note**: It is strongly recommended that you update to > v2.0.0 to address - known security vulnerabilities in prior versions when both symmetric and - asymmetric keys are used together. -- Update signature for `JWT::decode(...)` to require an array of supported - algorithms to use when verifying token signatures. - - Tests ----- Run the tests using phpunit: diff --git a/composer.json b/composer.json index 5ef2ea2d..816cfd0b 100644 --- a/composer.json +++ b/composer.json @@ -20,10 +20,11 @@ ], "license": "BSD-3-Clause", "require": { - "php": "^7.1||^8.0" + "php": "^8.0" }, "suggest": { - "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "ext-sodium": "Support EdDSA (Ed25519) signatures" }, "autoload": { "psr-4": { @@ -31,6 +32,11 @@ } }, "require-dev": { - "phpunit/phpunit": "^7.5||9.5" + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 092a662c..31195a91 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,7 +8,7 @@ convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" - bootstrap="tests/bootstrap.php" + bootstrap="vendor/autoload.php" > diff --git a/src/BeforeValidException.php b/src/BeforeValidException.php index c147852b..595164bf 100644 --- a/src/BeforeValidException.php +++ b/src/BeforeValidException.php @@ -2,6 +2,17 @@ namespace Firebase\JWT; -class BeforeValidException extends \UnexpectedValueException +class BeforeValidException extends \UnexpectedValueException implements JWTExceptionWithPayloadInterface { + private object $payload; + + public function setPayload(object $payload): void + { + $this->payload = $payload; + } + + public function getPayload(): object + { + return $this->payload; + } } diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php new file mode 100644 index 00000000..8e8e8d68 --- /dev/null +++ b/src/CachedKeySet.php @@ -0,0 +1,274 @@ + + */ +class CachedKeySet implements ArrayAccess +{ + /** + * @var string + */ + private $jwksUri; + /** + * @var ClientInterface + */ + private $httpClient; + /** + * @var RequestFactoryInterface + */ + private $httpFactory; + /** + * @var CacheItemPoolInterface + */ + private $cache; + /** + * @var ?int + */ + private $expiresAfter; + /** + * @var ?CacheItemInterface + */ + private $cacheItem; + /** + * @var array> + */ + private $keySet; + /** + * @var string + */ + private $cacheKey; + /** + * @var string + */ + private $cacheKeyPrefix = 'jwks'; + /** + * @var int + */ + private $maxKeyLength = 64; + /** + * @var bool + */ + private $rateLimit; + /** + * @var string + */ + private $rateLimitCacheKey; + /** + * @var int + */ + private $maxCallsPerMinute = 10; + /** + * @var string|null + */ + private $defaultAlg; + + public function __construct( + string $jwksUri, + ClientInterface $httpClient, + RequestFactoryInterface $httpFactory, + CacheItemPoolInterface $cache, + ?int $expiresAfter = null, + bool $rateLimit = false, + ?string $defaultAlg = null + ) { + $this->jwksUri = $jwksUri; + $this->httpClient = $httpClient; + $this->httpFactory = $httpFactory; + $this->cache = $cache; + $this->expiresAfter = $expiresAfter; + $this->rateLimit = $rateLimit; + $this->defaultAlg = $defaultAlg; + $this->setCacheKeys(); + } + + /** + * @param string $keyId + * @return Key + */ + public function offsetGet($keyId): Key + { + if (!$this->keyIdExists($keyId)) { + throw new OutOfBoundsException('Key ID not found'); + } + return JWK::parseKey($this->keySet[$keyId], $this->defaultAlg); + } + + /** + * @param string $keyId + * @return bool + */ + public function offsetExists($keyId): bool + { + return $this->keyIdExists($keyId); + } + + /** + * @param string $offset + * @param Key $value + */ + public function offsetSet($offset, $value): void + { + throw new LogicException('Method not implemented'); + } + + /** + * @param string $offset + */ + public function offsetUnset($offset): void + { + throw new LogicException('Method not implemented'); + } + + /** + * @return array + */ + private function formatJwksForCache(string $jwks): array + { + $jwks = json_decode($jwks, true); + + if (!isset($jwks['keys'])) { + throw new UnexpectedValueException('"keys" member must exist in the JWK Set'); + } + + if (empty($jwks['keys'])) { + throw new InvalidArgumentException('JWK Set did not contain any keys'); + } + + $keys = []; + foreach ($jwks['keys'] as $k => $v) { + $kid = isset($v['kid']) ? $v['kid'] : $k; + $keys[(string) $kid] = $v; + } + + return $keys; + } + + private function keyIdExists(string $keyId): bool + { + if (null === $this->keySet) { + $item = $this->getCacheItem(); + // Try to load keys from cache + if ($item->isHit()) { + // item found! retrieve it + $this->keySet = $item->get(); + // If the cached item is a string, the JWKS response was cached (previous behavior). + // Parse this into expected format array instead. + if (\is_string($this->keySet)) { + $this->keySet = $this->formatJwksForCache($this->keySet); + } + } + } + + if (!isset($this->keySet[$keyId])) { + if ($this->rateLimitExceeded()) { + return false; + } + $request = $this->httpFactory->createRequest('GET', $this->jwksUri); + $jwksResponse = $this->httpClient->sendRequest($request); + if ($jwksResponse->getStatusCode() !== 200) { + throw new UnexpectedValueException( + \sprintf('HTTP Error: %d %s for URI "%s"', + $jwksResponse->getStatusCode(), + $jwksResponse->getReasonPhrase(), + $this->jwksUri, + ), + $jwksResponse->getStatusCode() + ); + } + $this->keySet = $this->formatJwksForCache((string) $jwksResponse->getBody()); + + if (!isset($this->keySet[$keyId])) { + return false; + } + + $item = $this->getCacheItem(); + $item->set($this->keySet); + if ($this->expiresAfter) { + $item->expiresAfter($this->expiresAfter); + } + $this->cache->save($item); + } + + return true; + } + + private function rateLimitExceeded(): bool + { + if (!$this->rateLimit) { + return false; + } + + $cacheItem = $this->cache->getItem($this->rateLimitCacheKey); + + $cacheItemData = []; + if ($cacheItem->isHit() && \is_array($data = $cacheItem->get())) { + $cacheItemData = $data; + } + + $callsPerMinute = $cacheItemData['callsPerMinute'] ?? 0; + $expiry = $cacheItemData['expiry'] ?? new \DateTime('+60 seconds', new \DateTimeZone('UTC')); + + if (++$callsPerMinute > $this->maxCallsPerMinute) { + return true; + } + + $cacheItem->set(['expiry' => $expiry, 'callsPerMinute' => $callsPerMinute]); + $cacheItem->expiresAt($expiry); + $this->cache->save($cacheItem); + return false; + } + + private function getCacheItem(): CacheItemInterface + { + if (\is_null($this->cacheItem)) { + $this->cacheItem = $this->cache->getItem($this->cacheKey); + } + + return $this->cacheItem; + } + + private function setCacheKeys(): void + { + if (empty($this->jwksUri)) { + throw new RuntimeException('JWKS URI is empty'); + } + + // ensure we do not have illegal characters + $key = preg_replace('|[^a-zA-Z0-9_\.!]|', '', $this->jwksUri); + + // add prefix + $key = $this->cacheKeyPrefix . $key; + + // Hash keys if they exceed $maxKeyLength of 64 + if (\strlen($key) > $this->maxKeyLength) { + $key = substr(hash('sha256', $key), 0, $this->maxKeyLength); + } + + $this->cacheKey = $key; + + if ($this->rateLimit) { + // add prefix + $rateLimitKey = $this->cacheKeyPrefix . 'ratelimit' . $key; + + // Hash keys if they exceed $maxKeyLength of 64 + if (\strlen($rateLimitKey) > $this->maxKeyLength) { + $rateLimitKey = substr(hash('sha256', $rateLimitKey), 0, $this->maxKeyLength); + } + + $this->rateLimitCacheKey = $rateLimitKey; + } + } +} diff --git a/src/ExpiredException.php b/src/ExpiredException.php index 81ba52d4..25f44513 100644 --- a/src/ExpiredException.php +++ b/src/ExpiredException.php @@ -2,6 +2,29 @@ namespace Firebase\JWT; -class ExpiredException extends \UnexpectedValueException +class ExpiredException extends \UnexpectedValueException implements JWTExceptionWithPayloadInterface { + private object $payload; + + private ?int $timestamp = null; + + public function setPayload(object $payload): void + { + $this->payload = $payload; + } + + public function getPayload(): object + { + return $this->payload; + } + + public function setTimestamp(int $timestamp): void + { + $this->timestamp = $timestamp; + } + + public function getTimestamp(): ?int + { + return $this->timestamp; + } } diff --git a/src/JWK.php b/src/JWK.php index dbc446e6..d5175b21 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -20,10 +20,29 @@ */ class JWK { + private const OID = '1.2.840.10045.2.1'; + private const ASN1_OBJECT_IDENTIFIER = 0x06; + private const ASN1_SEQUENCE = 0x10; // also defined in JWT + private const ASN1_BIT_STRING = 0x03; + private const EC_CURVES = [ + 'P-256' => '1.2.840.10045.3.1.7', // Len: 64 + 'secp256k1' => '1.3.132.0.10', // Len: 64 + 'P-384' => '1.3.132.0.34', // Len: 96 + // 'P-521' => '1.3.132.0.35', // Len: 132 (not supported) + ]; + + // For keys with "kty" equal to "OKP" (Octet Key Pair), the "crv" parameter must contain the key subtype. + // This library supports the following subtypes: + private const OKP_SUBTYPES = [ + 'Ed25519' => true, // RFC 8037 + ]; + /** * Parse a set of JWK keys * * @param array $jwks The JSON Web Key Set as an associative array + * @param string $defaultAlg The algorithm for the Key object if "alg" is not set in the + * JSON Web Key Set * * @return array An associative array of key IDs (kid) to Key objects * @@ -33,7 +52,7 @@ class JWK * * @uses parseKey */ - public static function parseKeySet(array $jwks): array + public static function parseKeySet(#[\SensitiveParameter] array $jwks, ?string $defaultAlg = null): array { $keys = []; @@ -47,7 +66,7 @@ public static function parseKeySet(array $jwks): array foreach ($jwks['keys'] as $k => $v) { $kid = isset($v['kid']) ? $v['kid'] : $k; - if ($key = self::parseKey($v)) { + if ($key = self::parseKey($v, $defaultAlg)) { $keys[(string) $kid] = $key; } } @@ -63,6 +82,8 @@ public static function parseKeySet(array $jwks): array * Parse a JWK key * * @param array $jwk An individual JWK + * @param string $defaultAlg The algorithm for the Key object if "alg" is not set in the + * JSON Web Key Set * * @return Key The key object for the JWK * @@ -72,7 +93,7 @@ public static function parseKeySet(array $jwks): array * * @uses createPemFromModulusAndExponent */ - public static function parseKey(array $jwk): ?Key + public static function parseKey(#[\SensitiveParameter] array $jwk, ?string $defaultAlg = null): ?Key { if (empty($jwk)) { throw new InvalidArgumentException('JWK must not be empty'); @@ -83,10 +104,14 @@ public static function parseKey(array $jwk): ?Key } if (!isset($jwk['alg'])) { - // The "alg" parameter is optional in a KTY, but is required for parsing in - // this library. Add it manually to your JWK array if it doesn't already exist. - // @see https://datatracker.ietf.org/doc/html/rfc7517#section-4.4 - throw new UnexpectedValueException('JWK must contain an "alg" parameter'); + if (\is_null($defaultAlg)) { + // The "alg" parameter is optional in a KTY, but an algorithm is required + // for parsing in this library. Use the $defaultAlg parameter when parsing the + // key set in order to prevent this error. + // @see https://datatracker.ietf.org/doc/html/rfc7517#section-4.4 + throw new UnexpectedValueException('JWK must contain an "alg" parameter'); + } + $jwk['alg'] = $defaultAlg; } switch ($jwk['kty']) { @@ -106,14 +131,99 @@ public static function parseKey(array $jwk): ?Key ); } return new Key($publicKey, $jwk['alg']); + case 'EC': + if (isset($jwk['d'])) { + // The key is actually a private key + throw new UnexpectedValueException('Key data must be for a public key'); + } + + if (empty($jwk['crv'])) { + throw new UnexpectedValueException('crv not set'); + } + + if (!isset(self::EC_CURVES[$jwk['crv']])) { + throw new DomainException('Unrecognised or unsupported EC curve'); + } + + if (empty($jwk['x']) || empty($jwk['y'])) { + throw new UnexpectedValueException('x and y not set'); + } + + $publicKey = self::createPemFromCrvAndXYCoordinates($jwk['crv'], $jwk['x'], $jwk['y']); + return new Key($publicKey, $jwk['alg']); + case 'OKP': + if (isset($jwk['d'])) { + // The key is actually a private key + throw new UnexpectedValueException('Key data must be for a public key'); + } + + if (!isset($jwk['crv'])) { + throw new UnexpectedValueException('crv not set'); + } + + if (empty(self::OKP_SUBTYPES[$jwk['crv']])) { + throw new DomainException('Unrecognised or unsupported OKP key subtype'); + } + + if (empty($jwk['x'])) { + throw new UnexpectedValueException('x not set'); + } + + // This library works internally with EdDSA keys (Ed25519) encoded in standard base64. + $publicKey = JWT::convertBase64urlToBase64($jwk['x']); + return new Key($publicKey, $jwk['alg']); + case 'oct': + if (!isset($jwk['k'])) { + throw new UnexpectedValueException('k not set'); + } + + return new Key(JWT::urlsafeB64Decode($jwk['k']), $jwk['alg']); default: - // Currently only RSA is supported break; } return null; } + /** + * Converts the EC JWK values to pem format. + * + * @param string $crv The EC curve (only P-256 & P-384 is supported) + * @param string $x The EC x-coordinate + * @param string $y The EC y-coordinate + * + * @return string + */ + private static function createPemFromCrvAndXYCoordinates(string $crv, string $x, string $y): string + { + $pem = + self::encodeDER( + self::ASN1_SEQUENCE, + self::encodeDER( + self::ASN1_SEQUENCE, + self::encodeDER( + self::ASN1_OBJECT_IDENTIFIER, + self::encodeOID(self::OID) + ) + . self::encodeDER( + self::ASN1_OBJECT_IDENTIFIER, + self::encodeOID(self::EC_CURVES[$crv]) + ) + ) . + self::encodeDER( + self::ASN1_BIT_STRING, + \chr(0x00) . \chr(0x04) + . JWT::urlsafeB64Decode($x) + . JWT::urlsafeB64Decode($y) + ) + ); + + return \sprintf( + "-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n", + wordwrap(base64_encode($pem), 64, "\n", true) + ); + } + /** * Create a public key represented in PEM format from RSA modulus and exponent information * @@ -154,11 +264,9 @@ private static function createPemFromModulusAndExponent( $rsaOID . $rsaPublicKey ); - $rsaPublicKey = "-----BEGIN PUBLIC KEY-----\r\n" . + return "-----BEGIN PUBLIC KEY-----\r\n" . \chunk_split(\base64_encode($rsaPublicKey), 64) . '-----END PUBLIC KEY-----'; - - return $rsaPublicKey; } /** @@ -180,4 +288,68 @@ private static function encodeLength(int $length): string return \pack('Ca*', 0x80 | \strlen($temp), $temp); } + + /** + * Encodes a value into a DER object. + * Also defined in Firebase\JWT\JWT + * + * @param int $type DER tag + * @param string $value the value to encode + * @return string the encoded object + */ + private static function encodeDER(int $type, string $value): string + { + $tag_header = 0; + if ($type === self::ASN1_SEQUENCE) { + $tag_header |= 0x20; + } + + // Type + $der = \chr($tag_header | $type); + + // Length + $der .= \chr(\strlen($value)); + + return $der . $value; + } + + /** + * Encodes a string into a DER-encoded OID. + * + * @param string $oid the OID string + * @return string the binary DER-encoded OID + */ + private static function encodeOID(string $oid): string + { + $octets = explode('.', $oid); + + // Get the first octet + $first = (int) array_shift($octets); + $second = (int) array_shift($octets); + $oid = \chr($first * 40 + $second); + + // Iterate over subsequent octets + foreach ($octets as $octet) { + if ($octet == 0) { + $oid .= \chr(0x00); + continue; + } + $bin = ''; + + while ($octet) { + $bin .= \chr(0x80 | ($octet & 0x7f)); + $octet >>= 7; + } + $bin[0] = $bin[0] & \chr(0x7f); + + // Convert to big endian if necessary + if (pack('V', 65534) == pack('L', 65534)) { + $oid .= strrev($bin); + } else { + $oid .= $bin; + } + } + + return $oid; + } } diff --git a/src/JWT.php b/src/JWT.php index f6a4772c..c18e4cc0 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -3,15 +3,14 @@ namespace Firebase\JWT; use ArrayAccess; +use DateTime; use DomainException; use Exception; use InvalidArgumentException; use OpenSSLAsymmetricKey; use OpenSSLCertificate; -use TypeError; -use UnexpectedValueException; -use DateTime; use stdClass; +use UnexpectedValueException; /** * JSON Web Token implementation, based on this spec: @@ -32,6 +31,8 @@ class JWT private const ASN1_SEQUENCE = 0x10; private const ASN1_BIT_STRING = 0x03; + private const RSA_KEY_MIN_LENGTH=2048; + /** * When checking nbf, iat or expiration times, * we want to provide some extra leeway time to @@ -41,12 +42,22 @@ class JWT */ public static $leeway = 0; + /** + * Allow the current timestamp to be specified. + * Useful for fixing a value within unit testing. + * Will default to PHP time() value if null. + * + * @var ?int + */ + public static $timestamp = null; + /** * @var array */ public static $supported_algs = [ 'ES384' => ['openssl', 'SHA384'], 'ES256' => ['openssl', 'SHA256'], + 'ES256K' => ['openssl', 'SHA256'], 'HS256' => ['hash_hmac', 'SHA256'], 'HS384' => ['hash_hmac', 'SHA384'], 'HS512' => ['hash_hmac', 'SHA512'], @@ -60,15 +71,20 @@ class JWT * Decodes a JWT string into a PHP object. * * @param string $jwt The JWT - * @param Key|array $keyOrKeyArray The Key or associative array of key IDs (kid) to Key objects. - * If the algorithm used is asymmetric, this is the public key - * Each Key object contains an algorithm and matching key. - * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', - * 'HS512', 'RS256', 'RS384', and 'RS512' + * @param Key|ArrayAccess|array $keyOrKeyArray The Key or associative array of key IDs + * (kid) to Key objects. + * If the algorithm used is asymmetric, this is + * the public key. + * Each Key object contains an algorithm and + * matching key. + * Supported algorithms are 'ES384','ES256', + * 'HS256', 'HS384', 'HS512', 'RS256', 'RS384' + * and 'RS512'. + * @param stdClass $headers Optional. Populates stdClass with headers. * * @return stdClass The JWT's payload as a PHP object * - * @throws InvalidArgumentException Provided key/key-array was empty + * @throws InvalidArgumentException Provided key/key-array was empty or malformed * @throws DomainException Provided JWT is malformed * @throws UnexpectedValueException Provided JWT was invalid * @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed @@ -81,16 +97,17 @@ class JWT */ public static function decode( string $jwt, - $keyOrKeyArray + #[\SensitiveParameter] $keyOrKeyArray, + ?stdClass &$headers = null ): stdClass { // Validate JWT - $timestamp = \time(); + $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; if (empty($keyOrKeyArray)) { throw new InvalidArgumentException('Key may not be empty'); } $tks = \explode('.', $jwt); - if (\count($tks) != 3) { + if (\count($tks) !== 3) { throw new UnexpectedValueException('Wrong number of segments'); } list($headb64, $bodyb64, $cryptob64) = $tks; @@ -98,13 +115,30 @@ public static function decode( if (null === ($header = static::jsonDecode($headerRaw))) { throw new UnexpectedValueException('Invalid header encoding'); } + if ($headers !== null) { + $headers = $header; + } $payloadRaw = static::urlsafeB64Decode($bodyb64); if (null === ($payload = static::jsonDecode($payloadRaw))) { throw new UnexpectedValueException('Invalid claims encoding'); } + if (\is_array($payload)) { + // prevent PHP Fatal Error in edge-cases when payload is empty array + $payload = (object) $payload; + } if (!$payload instanceof stdClass) { throw new UnexpectedValueException('Payload must be a JSON object'); } + if (isset($payload->iat) && !\is_numeric($payload->iat)) { + throw new UnexpectedValueException('Payload iat must be a number'); + } + if (isset($payload->nbf) && !\is_numeric($payload->nbf)) { + throw new UnexpectedValueException('Payload nbf must be a number'); + } + if (isset($payload->exp) && !\is_numeric($payload->exp)) { + throw new UnexpectedValueException('Payload exp must be a number'); + } + $sig = static::urlsafeB64Decode($cryptob64); if (empty($header->alg)) { throw new UnexpectedValueException('Empty algorithm'); @@ -120,44 +154,53 @@ public static function decode( // See issue #351 throw new UnexpectedValueException('Incorrect key for this algorithm'); } - if ($header->alg === 'ES256' || $header->alg === 'ES384') { - // OpenSSL expects an ASN.1 DER sequence for ES256/ES384 signatures + if (\in_array($header->alg, ['ES256', 'ES256K', 'ES384'], true)) { + // OpenSSL expects an ASN.1 DER sequence for ES256/ES256K/ES384 signatures $sig = self::signatureToDER($sig); } - if (!self::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) { + if (!self::verify("{$headb64}.{$bodyb64}", $sig, $key->getKeyMaterial(), $header->alg)) { throw new SignatureInvalidException('Signature verification failed'); } // Check the nbf if it is defined. This is the time that the // token can actually be used. If it's not yet that time, abort. - if (isset($payload->nbf) && $payload->nbf > ($timestamp + static::$leeway)) { - throw new BeforeValidException( - 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $payload->nbf) + if (isset($payload->nbf) && floor($payload->nbf) > ($timestamp + static::$leeway)) { + $ex = new BeforeValidException( + 'Cannot handle token with nbf prior to ' . \date(DateTime::ATOM, (int) floor($payload->nbf)) ); + $ex->setPayload($payload); + throw $ex; } // Check that this token has been created before 'now'. This prevents // using tokens that have been created for later use (and haven't // correctly used the nbf claim). - if (isset($payload->iat) && $payload->iat > ($timestamp + static::$leeway)) { - throw new BeforeValidException( - 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $payload->iat) + if (!isset($payload->nbf) && isset($payload->iat) && floor($payload->iat) > ($timestamp + static::$leeway)) { + $ex = new BeforeValidException( + 'Cannot handle token with iat prior to ' . \date(DateTime::ATOM, (int) floor($payload->iat)) ); + $ex->setPayload($payload); + throw $ex; } // Check if this token has expired. if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) { - throw new ExpiredException('Expired token'); + $ex = new ExpiredException('Expired token'); + $ex->setPayload($payload); + $ex->setTimestamp($timestamp); + throw $ex; } return $payload; } /** - * Converts and signs a PHP object or array into a JWT string. + * Converts and signs a PHP array into a JWT string. * * @param array $payload PHP array - * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. + * @param string $alg Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256', + * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' * @param string $keyId * @param array $head An array with header elements to attach * @@ -168,18 +211,19 @@ public static function decode( */ public static function encode( array $payload, - $key, + #[\SensitiveParameter] $key, string $alg, - string $keyId = null, - array $head = null + ?string $keyId = null, + ?array $head = null ): string { - $header = ['typ' => 'JWT', 'alg' => $alg]; + $header = ['typ' => 'JWT']; + if (isset($head)) { + $header = \array_merge($header, $head); + } + $header['alg'] = $alg; if ($keyId !== null) { $header['kid'] = $keyId; } - if (isset($head) && \is_array($head)) { - $header = \array_merge($head, $header); - } $segments = []; $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($header)); $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($payload)); @@ -195,9 +239,9 @@ public static function encode( * Sign a string with a given key and algorithm. * * @param string $msg The message to sign - * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. - * @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', - * 'HS512', 'RS256', 'RS384', and 'RS512' + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. + * @param string $alg Supported algorithms are 'EdDSA', 'ES384', 'ES256', 'ES256K', 'HS256', + * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' * * @return string An encrypted message * @@ -205,7 +249,7 @@ public static function encode( */ public static function sign( string $msg, - $key, + #[\SensitiveParameter] $key, string $alg ): string { if (empty(static::$supported_algs[$alg])) { @@ -214,33 +258,45 @@ public static function sign( list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'hash_hmac': - if (!is_string($key)) { + if (!\is_string($key)) { throw new InvalidArgumentException('key must be a string when using hmac'); } + self::validateHmacKeyLength($key, $algorithm); return \hash_hmac($algorithm, $msg, $key, true); case 'openssl': $signature = ''; - $success = \openssl_sign($msg, $signature, $key, $algorithm); // @phpstan-ignore-line + if (!$key = openssl_pkey_get_private($key)) { + throw new DomainException('OpenSSL unable to validate key'); + } + if (str_starts_with($alg, 'RS')) { + self::validateRsaKeyLength($key); + } elseif (str_starts_with($alg, 'ES')) { + self::validateEcKeyLength($key, $alg); + } + $success = \openssl_sign($msg, $signature, $key, $algorithm); if (!$success) { - throw new DomainException("OpenSSL unable to sign data"); + throw new DomainException('OpenSSL unable to sign data'); } - if ($alg === 'ES256') { + if ($alg === 'ES256' || $alg === 'ES256K') { $signature = self::signatureFromDER($signature, 256); } elseif ($alg === 'ES384') { $signature = self::signatureFromDER($signature, 384); } return $signature; case 'sodium_crypto': - if (!function_exists('sodium_crypto_sign_detached')) { + if (!\function_exists('sodium_crypto_sign_detached')) { throw new DomainException('libsodium is not available'); } - if (!is_string($key)) { + if (!\is_string($key)) { throw new InvalidArgumentException('key must be a string when using EdDSA'); } try { // The last non-empty line is used as the key. $lines = array_filter(explode("\n", $key)); $key = base64_decode((string) end($lines)); + if (\strlen($key) === 0) { + throw new DomainException('Key cannot be empty string'); + } return sodium_crypto_sign_detached($msg, $key); } catch (Exception $e) { throw new DomainException($e->getMessage(), 0, $e); @@ -256,7 +312,7 @@ public static function sign( * * @param string $msg The original message (header and body) * @param string $signature The original signature - * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial For Ed*, ES*, HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey * @param string $alg The algorithm * * @return bool @@ -266,7 +322,7 @@ public static function sign( private static function verify( string $msg, string $signature, - $keyMaterial, + #[\SensitiveParameter] $keyMaterial, string $alg ): bool { if (empty(static::$supported_algs[$alg])) { @@ -276,10 +332,19 @@ private static function verify( list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'openssl': - $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); // @phpstan-ignore-line + if (!$key = openssl_pkey_get_public($keyMaterial)) { + throw new DomainException('OpenSSL unable to validate key'); + } + if (str_starts_with($alg, 'RS')) { + self::validateRsaKeyLength($key); + } elseif (str_starts_with($alg, 'ES')) { + self::validateEcKeyLength($key, $alg); + } + $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); if ($success === 1) { return true; - } elseif ($success === 0) { + } + if ($success === 0) { return false; } // returns 1 on success, 0 on failure, -1 on error. @@ -287,25 +352,32 @@ private static function verify( 'OpenSSL error: ' . \openssl_error_string() ); case 'sodium_crypto': - if (!function_exists('sodium_crypto_sign_verify_detached')) { - throw new DomainException('libsodium is not available'); - } - if (!is_string($keyMaterial)) { - throw new InvalidArgumentException('key must be a string when using EdDSA'); - } - try { - // The last non-empty line is used as the key. - $lines = array_filter(explode("\n", $keyMaterial)); - $key = base64_decode((string) end($lines)); - return sodium_crypto_sign_verify_detached($signature, $msg, $key); - } catch (Exception $e) { - throw new DomainException($e->getMessage(), 0, $e); - } + if (!\function_exists('sodium_crypto_sign_verify_detached')) { + throw new DomainException('libsodium is not available'); + } + if (!\is_string($keyMaterial)) { + throw new InvalidArgumentException('key must be a string when using EdDSA'); + } + try { + // The last non-empty line is used as the key. + $lines = array_filter(explode("\n", $keyMaterial)); + $key = base64_decode((string) end($lines)); + if (\strlen($key) === 0) { + throw new DomainException('Key cannot be empty string'); + } + if (\strlen($signature) === 0) { + throw new DomainException('Signature cannot be empty string'); + } + return sodium_crypto_sign_verify_detached($signature, $msg, $key); + } catch (Exception $e) { + throw new DomainException($e->getMessage(), 0, $e); + } case 'hash_hmac': default: - if (!is_string($keyMaterial)) { + if (!\is_string($keyMaterial)) { throw new InvalidArgumentException('key must be a string when using hmac'); } + self::validateHmacKeyLength($keyMaterial, $algorithm); $hash = \hash_hmac($algorithm, $msg, $keyMaterial, true); return self::constantTimeEquals($hash, $signature); } @@ -343,15 +415,10 @@ public static function jsonDecode(string $input) */ public static function jsonEncode(array $input): string { - if (PHP_VERSION_ID >= 50400) { - $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); - } else { - // PHP 5.3 only - $json = \json_encode($input); - } + $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); if ($errno = \json_last_error()) { self::handleJsonError($errno); - } elseif ($json === 'null' && $input !== null) { + } elseif ($json === 'null') { throw new DomainException('Null result with non-null input'); } if ($json === false) { @@ -370,13 +437,28 @@ public static function jsonEncode(array $input): string * @throws InvalidArgumentException invalid base64 characters */ public static function urlsafeB64Decode(string $input): string + { + return \base64_decode(self::convertBase64UrlToBase64($input)); + } + + /** + * Convert a string in the base64url (URL-safe Base64) encoding to standard base64. + * + * @param string $input A Base64 encoded string with URL-safe characters (-_ and no padding) + * + * @return string A Base64 encoded string with standard characters (+/) and padding (=), when + * needed. + * + * @see https://www.rfc-editor.org/rfc/rfc4648 + */ + public static function convertBase64UrlToBase64(string $input): string { $remainder = \strlen($input) % 4; if ($remainder) { $padlen = 4 - $remainder; $input .= \str_repeat('=', $padlen); } - return \base64_decode(\strtr($input, '-_', '+/')); + return \strtr($input, '-_', '+/'); } /** @@ -395,7 +477,7 @@ public static function urlsafeB64Encode(string $input): string /** * Determine if an algorithm has been provided for each Key * - * @param Key|array $keyOrKeyArray + * @param Key|ArrayAccess|array $keyOrKeyArray * @param string|null $kid * * @throws UnexpectedValueException @@ -403,24 +485,22 @@ public static function urlsafeB64Encode(string $input): string * @return Key */ private static function getKey( - $keyOrKeyArray, + #[\SensitiveParameter] $keyOrKeyArray, ?string $kid ): Key { if ($keyOrKeyArray instanceof Key) { return $keyOrKeyArray; } - foreach ($keyOrKeyArray as $keyId => $key) { - if (!$key instanceof Key) { - throw new TypeError( - '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an ' - . 'array of Firebase\JWT\Key keys' - ); - } - } - if (!isset($kid)) { + if (empty($kid) && $kid !== '0') { throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); } + + if ($keyOrKeyArray instanceof CachedKeySet) { + // Skip "isset" check, as this will automatically refresh if not set + return $keyOrKeyArray[$kid]; + } + if (!isset($keyOrKeyArray[$kid])) { throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); } @@ -499,7 +579,7 @@ private static function signatureToDER(string $sig): string { // Separate the signature into r-value and s-value $length = max(1, (int) (\strlen($sig) / 2)); - list($r, $s) = \str_split($sig, $length > 0 ? $length : 1); + list($r, $s) = \str_split($sig, $length); // Trim leading zeros $r = \ltrim($r, "\x00"); @@ -599,7 +679,7 @@ private static function readDER(string $der, int $offset = 0): array } // Value - if ($type == self::ASN1_BIT_STRING) { + if ($type === self::ASN1_BIT_STRING) { $pos++; // Skip the first contents octet (padding indicator) $data = \substr($der, $pos, $len - 1); $pos += $len - 1; @@ -612,4 +692,57 @@ private static function readDER(string $der, int $offset = 0): array return [$pos, $data]; } + + /** + * Validate HMAC key length + * + * @param string $key HMAC key material + * @param string $algorithm The algorithm + * + * @throws DomainException Provided key is too short + */ + private static function validateHmacKeyLength(string $key, string $algorithm): void + { + $keyLength = \strlen($key) * 8; + $minKeyLength = (int) \str_replace('SHA', '', $algorithm); + if ($keyLength < $minKeyLength) { + throw new DomainException('Provided key is too short'); + } + } + + /** + * Validate RSA key length + * + * @param OpenSSLAsymmetricKey $key RSA key material + * @throws DomainException Provided key is too short + */ + private static function validateRsaKeyLength(#[\SensitiveParameter] OpenSSLAsymmetricKey $key): void + { + if (!$keyDetails = openssl_pkey_get_details($key)) { + throw new DomainException('Unable to validate key'); + } + if ($keyDetails['bits'] < self::RSA_KEY_MIN_LENGTH) { + throw new DomainException('Provided key is too short'); + } + } + + /** + * Validate RSA key length + * + * @param OpenSSLAsymmetricKey $key RSA key material + * @param string $algorithm The algorithm + * @throws DomainException Provided key is too short + */ + private static function validateEcKeyLength( + #[\SensitiveParameter] OpenSSLAsymmetricKey $key, + string $algorithm + ): void { + if (!$keyDetails = openssl_pkey_get_details($key)) { + throw new DomainException('Unable to validate key'); + } + $minKeyLength = (int) \str_replace('ES', '', $algorithm); + if ($keyDetails['bits'] < $minKeyLength) { + throw new DomainException('Provided key is too short'); + } + } } diff --git a/src/JWTExceptionWithPayloadInterface.php b/src/JWTExceptionWithPayloadInterface.php new file mode 100644 index 00000000..7933ed68 --- /dev/null +++ b/src/JWTExceptionWithPayloadInterface.php @@ -0,0 +1,20 @@ +keyMaterial = $keyMaterial; - $this->algorithm = $algorithm; } /** @@ -55,7 +45,7 @@ public function getAlgorithm(): string } /** - * @return string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate + * @return string|OpenSSLAsymmetricKey|OpenSSLCertificate */ public function getKeyMaterial() { diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php new file mode 100644 index 00000000..39bbc919 --- /dev/null +++ b/tests/CachedKeySetTest.php @@ -0,0 +1,649 @@ + ['kid' => 'foo', 'kty' => 'RSA', 'alg' => 'foo', 'n' => '', 'e' => '']]; + private $testJwks2 = '{"keys": [{"kid":"bar","kty":"RSA","alg":"bar","n":"","e":""}]}'; + private $testJwks3 = '{"keys": [{"kid":"baz","kty":"RSA","n":"","e":""}]}'; + + private $googleRsaUri = 'https://www.googleapis.com/oauth2/v3/certs'; + private $googleEcUri = 'https://www.gstatic.com/iap/verify/public_key-jwk'; + + public function testEmptyUriThrowsException() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('JWKS URI is empty'); + + $cachedKeySet = new CachedKeySet( + '', + $this->prophesize(ClientInterface::class)->reveal(), + $this->prophesize(RequestFactoryInterface::class)->reveal(), + $this->prophesize(CacheItemPoolInterface::class)->reveal() + ); + + $cachedKeySet['foo']; + } + + public function testOffsetSetThrowsException() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Method not implemented'); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->prophesize(ClientInterface::class)->reveal(), + $this->prophesize(RequestFactoryInterface::class)->reveal(), + $this->prophesize(CacheItemPoolInterface::class)->reveal() + ); + + $cachedKeySet['foo'] = 'bar'; + } + + public function testOffsetUnsetThrowsException() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Method not implemented'); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->prophesize(ClientInterface::class)->reveal(), + $this->prophesize(RequestFactoryInterface::class)->reveal(), + $this->prophesize(CacheItemPoolInterface::class)->reveal() + ); + + unset($cachedKeySet['foo']); + } + + public function testOutOfBoundsThrowsException() + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Key ID not found'); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks1), + $this->getMockHttpFactory(), + $this->getMockEmptyCache() + ); + + // keyID doesn't exist + $cachedKeySet['bar']; + } + + public function testInvalidHttpResponseThrowsException() + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('HTTP Error: 404 URL not found'); + $this->expectExceptionCode(404); + + $response = $this->prophesize('Psr\Http\Message\ResponseInterface'); + $response->getStatusCode() + ->shouldBeCalled() + ->willReturn(404); + $response->getReasonPhrase() + ->shouldBeCalledTimes(1) + ->willReturn('URL not found'); + + $http = $this->prophesize(ClientInterface::class); + $http->sendRequest(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($response->reveal()); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $http->reveal(), + $this->getMockHttpFactory(), + $this->getMockEmptyCache() + ); + + isset($cachedKeySet[0]); + } + + public function testWithExistingKeyId() + { + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks1), + $this->getMockHttpFactory(), + $this->getMockEmptyCache() + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); + } + + public function testWithDefaultAlg() + { + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks3), + $this->getMockHttpFactory(), + $this->getMockEmptyCache(), + null, + false, + 'baz256' + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['baz']); + $this->assertSame('baz256', $cachedKeySet['baz']->getAlgorithm()); + } + + public function testKeyIdIsCached() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->willReturn(true); + $cacheItem->get() + ->willReturn($this->testCachedJwks1); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem($this->testJwksUriKey) + ->willReturn($cacheItem->reveal()); + $cache->save(Argument::any()) + ->willReturn(true); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->prophesize(ClientInterface::class)->reveal(), + $this->prophesize(RequestFactoryInterface::class)->reveal(), + $cache->reveal() + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); + } + + public function testCachedKeyIdRefresh() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->shouldBeCalledOnce() + ->willReturn(true); + $cacheItem->get() + ->shouldBeCalledOnce() + ->willReturn($this->testCachedJwks1); + $cacheItem->set(Argument::any()) + ->shouldBeCalledOnce() + ->will(function () { + return $this; + }); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem($this->testJwksUriKey) + ->shouldBeCalledOnce() + ->willReturn($cacheItem->reveal()); + $cache->save(Argument::any()) + ->shouldBeCalledOnce() + ->willReturn(true); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks2), // updated JWK + $this->getMockHttpFactory(), + $cache->reveal() + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); + + $this->assertInstanceOf(Key::class, $cachedKeySet['bar']); + $this->assertSame('bar', $cachedKeySet['bar']->getAlgorithm()); + } + + public function testKeyIdIsCachedFromPreviousFormat() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->willReturn(true); + $cacheItem->get() + ->willReturn($this->testJwks1); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem($this->testJwksUriKey) + ->willReturn($cacheItem->reveal()); + $cache->save(Argument::any()) + ->willReturn(true); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->prophesize(ClientInterface::class)->reveal(), + $this->prophesize(RequestFactoryInterface::class)->reveal(), + $cache->reveal() + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); + } + + public function testCachedKeyIdRefreshFromPreviousFormat() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->shouldBeCalledOnce() + ->willReturn(true); + $cacheItem->get() + ->shouldBeCalledOnce() + ->willReturn($this->testJwks1); + $cacheItem->set(Argument::any()) + ->shouldBeCalledOnce() + ->will(function () { + return $this; + }); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem($this->testJwksUriKey) + ->shouldBeCalledOnce() + ->willReturn($cacheItem->reveal()); + $cache->save(Argument::any()) + ->shouldBeCalledOnce() + ->willReturn(true); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks2), // updated JWK + $this->getMockHttpFactory(), + $cache->reveal() + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); + + $this->assertInstanceOf(Key::class, $cachedKeySet['bar']); + $this->assertSame('bar', $cachedKeySet['bar']->getAlgorithm()); + } + + public function testCacheItemWithExpiresAfter() + { + $expiresAfter = 10; + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->shouldBeCalledOnce() + ->willReturn(false); + $cacheItem->set(Argument::any()) + ->shouldBeCalledOnce() + ->will(function () { + return $this; + }); + $cacheItem->expiresAfter($expiresAfter) + ->shouldBeCalledOnce() + ->will(function () { + return $this; + }); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem($this->testJwksUriKey) + ->shouldBeCalledOnce() + ->willReturn($cacheItem->reveal()); + $cache->save(Argument::any()) + ->shouldBeCalledOnce(); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks1), + $this->getMockHttpFactory(), + $cache->reveal(), + $expiresAfter + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); + } + + public function testJwtVerify() + { + $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); + $payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')]; + $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); + + // format the cached value to match the expected format + $cachedJwks = []; + $rsaKeySet = file_get_contents(__DIR__ . '/data/rsa-jwkset.json'); + foreach (json_decode($rsaKeySet, true)['keys'] as $k => $v) { + $cachedJwks[$v['kid']] = $v; + } + + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->willReturn(true); + $cacheItem->get() + ->willReturn($cachedJwks); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem($this->testJwksUriKey) + ->willReturn($cacheItem->reveal()); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->prophesize(ClientInterface::class)->reveal(), + $this->prophesize(RequestFactoryInterface::class)->reveal(), + $cache->reveal() + ); + + $result = JWT::decode($msg, $cachedKeySet); + + $this->assertSame('foo', $result->sub); + } + + public function testRateLimit() + { + // We request the key 11 times, HTTP should only be called 10 times + $shouldBeCalledTimes = 10; + + // Instantiate the cached key set + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks1, $shouldBeCalledTimes), + $this->getMockHttpFactory($shouldBeCalledTimes), + new TestMemoryCacheItemPool(), + 10, // expires after seconds + true // enable rate limiting + ); + + $invalidKid = 'invalidkey'; + for ($i = 0; $i < 10; $i++) { + $this->assertFalse(isset($cachedKeySet[$invalidKid])); + } + // The 11th time does not call HTTP + $this->assertFalse(isset($cachedKeySet[$invalidKid])); + } + + public function testRateLimitWithExpiresAfter() + { + // We request the key 17 times, HTTP should only be called 15 times + $shouldBeCalledTimes = 10; + $cachedTimes = 2; + $afterExpirationTimes = 5; + + $totalHttpTimes = $shouldBeCalledTimes + $afterExpirationTimes; + + $cachePool = new TestMemoryCacheItemPool(); + + // Instantiate the cached key set + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks1, $totalHttpTimes), + $this->getMockHttpFactory($totalHttpTimes), + $cachePool, + 10, // expires after seconds + true // enable rate limiting + ); + + // Set the rate limit cache to expire after 1 second + $cacheItem = $cachePool->getItem('jwksratelimitjwkshttpsjwk.uri'); + $cacheItem->set([ + 'expiry' => new \DateTime('+1 second', new \DateTimeZone('UTC')), + 'callsPerMinute' => 0, + ]); + $cacheItem->expiresAfter(1); + $cachePool->save($cacheItem); + + $invalidKid = 'invalidkey'; + for ($i = 0; $i < $shouldBeCalledTimes; $i++) { + $this->assertFalse(isset($cachedKeySet[$invalidKid])); + } + + // The next calls do not call HTTP + for ($i = 0; $i < $cachedTimes; $i++) { + $this->assertFalse(isset($cachedKeySet[$invalidKid])); + } + + sleep(1); // wait for cache to expire + + // These calls DO call HTTP because the cache has expired + for ($i = 0; $i < $afterExpirationTimes; $i++) { + $this->assertFalse(isset($cachedKeySet[$invalidKid])); + } + } + + /** + * @dataProvider provideFullIntegration + */ + public function testFullIntegration(string $jwkUri): void + { + if (!class_exists(\GuzzleHttp\Psr7\HttpFactory::class)) { + self::markTestSkipped('Guzzle 7 only'); + } + // Create cache and http objects + $cache = new TestMemoryCacheItemPool(); + $http = new \GuzzleHttp\Client(); + $factory = new \GuzzleHttp\Psr7\HttpFactory(); + + // Determine "kid" dynamically, because these constantly change + $response = $http->get($jwkUri); + $json = (string) $response->getBody(); + $keys = json_decode($json, true); + $kid = $keys['keys'][0]['kid'] ?? null; + $this->assertNotNull($kid); + + // Instantiate the cached key set + $cachedKeySet = new CachedKeySet( + $jwkUri, + $http, + $factory, + $cache + ); + + $this->assertArrayHasKey($kid, $cachedKeySet); + $key = $cachedKeySet[$kid]; + $this->assertInstanceOf(Key::class, $key); + $this->assertSame($keys['keys'][0]['alg'], $key->getAlgorithm()); + } + + public function provideFullIntegration() + { + return [ + [$this->googleRsaUri], + [$this->googleEcUri, 'LYyP2g'] + ]; + } + + private function getMockHttpClient($testJwks, int $timesCalled = 1) + { + $body = $this->prophesize('Psr\Http\Message\StreamInterface'); + $body->__toString() + ->shouldBeCalledTimes($timesCalled) + ->willReturn($testJwks); + + $response = $this->prophesize('Psr\Http\Message\ResponseInterface'); + $response->getBody() + ->shouldBeCalledTimes($timesCalled) + ->willReturn($body->reveal()); + $response->getStatusCode() + ->shouldBeCalledTimes($timesCalled) + ->willReturn(200); + + $http = $this->prophesize(ClientInterface::class); + $http->sendRequest(Argument::any()) + ->shouldBeCalledTimes($timesCalled) + ->willReturn($response->reveal()); + + return $http->reveal(); + } + + private function getMockHttpFactory(int $timesCalled = 1) + { + $request = $this->prophesize('Psr\Http\Message\RequestInterface'); + $factory = $this->prophesize(RequestFactoryInterface::class); + $factory->createRequest('GET', $this->testJwksUri) + ->shouldBeCalledTimes($timesCalled) + ->willReturn($request->reveal()); + + return $factory->reveal(); + } + + private function getMockEmptyCache() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->shouldBeCalledOnce() + ->willReturn(false); + $cacheItem->set(Argument::any()) + ->will(function () { + return $this; + }); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem($this->testJwksUriKey) + ->shouldBeCalledOnce() + ->willReturn($cacheItem->reveal()); + $cache->save(Argument::any()) + ->willReturn(true); + + return $cache->reveal(); + } +} + +/** + * A cache item pool + */ +final class TestMemoryCacheItemPool implements CacheItemPoolInterface +{ + private $items; + private $deferredItems; + + public function getItem($key): CacheItemInterface + { + $item = current($this->getItems([$key])); + $item->expiresAt(null); // mimic symfony cache behavior + + return $item; + } + + public function getItems(array $keys = []): iterable + { + $items = []; + + foreach ($keys as $key) { + $items[$key] = $this->hasItem($key) ? clone $this->items[$key] : new TestMemoryCacheItem($key); + } + + return $items; + } + + public function hasItem($key): bool + { + return isset($this->items[$key]) && $this->items[$key]->isHit(); + } + + public function clear(): bool + { + $this->items = []; + $this->deferredItems = []; + + return true; + } + + public function deleteItem($key): bool + { + return $this->deleteItems([$key]); + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + unset($this->items[$key]); + } + + return true; + } + + public function save(CacheItemInterface $item): bool + { + $this->items[$item->getKey()] = $item; + + return true; + } + + public function saveDeferred(CacheItemInterface $item): bool + { + $this->deferredItems[$item->getKey()] = $item; + + return true; + } + + public function commit(): bool + { + foreach ($this->deferredItems as $item) { + $this->save($item); + } + + $this->deferredItems = []; + + return true; + } +} + +/** + * A cache item. + */ +final class TestMemoryCacheItem implements CacheItemInterface +{ + private $key; + private $value; + private $expiration; + private $isHit = false; + + public function __construct(string $key) + { + $this->key = $key; + } + + public function getKey(): string + { + return $this->key; + } + + public function get(): mixed + { + return $this->isHit() ? $this->value : null; + } + + public function isHit(): bool + { + if (!$this->isHit) { + return false; + } + + if ($this->expiration === null) { + return true; + } + + return $this->currentTime()->getTimestamp() < $this->expiration->getTimestamp(); + } + + public function set(mixed $value): static + { + $this->isHit = true; + $this->value = $value; + + return $this; + } + + public function expiresAt($expiration): static + { + $this->expiration = $expiration; + return $this; + } + + public function expiresAfter($time): static + { + $this->expiration = $this->currentTime()->add(new \DateInterval("PT{$time}S")); + return $this; + } + + protected function currentTime() + { + return new \DateTime('now', new \DateTimeZone('UTC')); + } +} diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 17dd4a62..db385c87 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -2,8 +2,8 @@ namespace Firebase\JWT; -use PHPUnit\Framework\TestCase; use InvalidArgumentException; +use PHPUnit\Framework\TestCase; use UnexpectedValueException; class JWKTest extends TestCase @@ -58,6 +58,18 @@ public function testParsePrivateKeyWithoutAlg() JWK::parseKeySet($jwkSet); } + public function testParsePrivateKeyWithoutAlgWithDefaultAlgParameter() + { + $jwkSet = json_decode( + file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), + true + ); + unset($jwkSet['keys'][0]['alg']); + + $jwks = JWK::parseKeySet($jwkSet, 'foo'); + $this->assertSame('foo', $jwks['jwk1']->getAlgorithm()); + } + public function testParseKeyWithEmptyDValue() { $jwkSet = json_decode( @@ -69,7 +81,7 @@ public function testParseKeyWithEmptyDValue() $jwkSet['keys'][0]['d'] = null; $keys = JWK::parseKeySet($jwkSet); - $this->assertTrue(is_array($keys)); + $this->assertTrue(\is_array($keys)); } public function testParseJwkKeySet() @@ -79,7 +91,7 @@ public function testParseJwkKeySet() true ); $keys = JWK::parseKeySet($jwkSet); - $this->assertTrue(is_array($keys)); + $this->assertTrue(\is_array($keys)); $this->assertArrayHasKey('jwk1', $keys); self::$keys = $keys; } @@ -115,17 +127,33 @@ public function testDecodeByJwkKeySetTokenExpired() } /** - * @depends testParseJwkKeySet + * @dataProvider provideDecodeByJwkKeySet */ - public function testDecodeByJwkKeySet() + public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg, $keyId) { - $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); + $privKey1 = file_get_contents(__DIR__ . '/data/' . $pemFile); $payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')]; - $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); + $msg = JWT::encode($payload, $privKey1, $alg, $keyId); - $result = JWT::decode($msg, self::$keys); + $jwkSet = json_decode( + file_get_contents(__DIR__ . '/data/' . $jwkFile), + true + ); - $this->assertEquals("foo", $result->sub); + $keys = JWK::parseKeySet($jwkSet); + $result = JWT::decode($msg, $keys); + + $this->assertSame('foo', $result->sub); + } + + public function provideDecodeByJwkKeySet() + { + return [ + ['rsa1-private.pem', 'rsa-jwkset.json', 'RS256', 'jwk1'], + ['ecdsa256-private.pem', 'ec-jwkset.json', 'ES256', 'jwk1'], + ['ecdsa384-private.pem', 'ec-jwkset.json', 'ES384', 'jwk4'], + ['ed25519-1.sec', 'ed25519-jwkset.json', 'EdDSA', 'jwk1'], + ]; } /** @@ -139,6 +167,66 @@ public function testDecodeByMultiJwkKeySet() $result = JWT::decode($msg, self::$keys); - $this->assertEquals("bar", $result->sub); + $this->assertSame('bar', $result->sub); + } + + public function testDecodeByOctetJwkKeySet() + { + $jwkSet = json_decode( + file_get_contents(__DIR__ . '/data/octet-jwkset.json'), + true + ); + $keys = JWK::parseKeySet($jwkSet); + $payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')]; + foreach ($keys as $keyId => $key) { + $msg = JWT::encode($payload, $key->getKeyMaterial(), $key->getAlgorithm(), $keyId); + $result = JWT::decode($msg, $keys); + + $this->assertSame('foo', $result->sub); + } + } + + public function testOctetJwkMissingK() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('k not set'); + + $badJwk = ['kty' => 'oct', 'alg' => 'HS256']; + $keys = JWK::parseKeySet(['keys' => [$badJwk]]); + } + + public function testParseKey() + { + // Use a known module and exponent, and ensure it parses as expected + $jwk = [ + 'alg' => 'RS256', + 'kty' => 'RSA', + 'n' => 'hsYvCPtkUV7SIxwkOkJsJfhwV_CMdXU5i0UmY2QEs-Pa7v0-0y-s4EjEDtsQ8Yow6hc670JhkGBcMzhU4DtrqNGROXebyOse5FX0m0UvWo1qXqNTf28uBKB990mY42Icr8sGjtOw8ajyT9kufbmXi3eZKagKpG0TDGK90oBEfoGzCxoFT87F95liNth_GoyU5S8-G3OqIqLlQCwxkI5s-g2qvg_aooALfh1rhvx2wt4EJVMSrdnxtPQSPAtZBiw5SwCnVglc6OnalVNvAB2JArbqC9GAzzz9pApAk28SYg5a4hPiPyqwRv-4X1CXEK8bO5VesIeRX0oDf7UoM-pVAw', + 'use' => 'sig', + 'e' => 'AQAB', + 'kid' => '838c06c62046c2d948affe137dd5310129f4d5d1' + ]; + + $key = JWK::parseKey($jwk); + $this->assertNotNull($key); + + $openSslKey = $key->getKeyMaterial(); + $pubKey = openssl_pkey_get_public($openSslKey); + $keyData = openssl_pkey_get_details($pubKey); + + $expectedPublicKey = <<assertEquals($expectedPublicKey, $keyData['key']); } } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 8b23ad6b..a1dd08a4 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -2,28 +2,40 @@ namespace Firebase\JWT; -use ArrayObject; -use PHPUnit\Framework\TestCase; use DomainException; use InvalidArgumentException; +use PHPUnit\Framework\TestCase; +use stdClass; use TypeError; use UnexpectedValueException; -use stdClass; class JWTTest extends TestCase { + private Key $hmacKey; + + public function setUp(): void + { + $this->hmacKey = $this->generateHmac256(); + } + public function testUrlSafeCharacters() { - $encoded = JWT::encode(['message' => 'f?'], 'a', 'HS256'); + $encoded = JWT::encode(['message' => 'f?'], $this->hmacKey->getKeyMaterial(), 'HS256'); $expected = new stdClass(); $expected->message = 'f?'; - $this->assertEquals($expected, JWT::decode($encoded, new Key('a', 'HS256'))); + $this->assertEquals($expected, JWT::decode($encoded, $this->hmacKey)); } public function testMalformedUtf8StringsFail() { $this->expectException(DomainException::class); - JWT::encode(['message' => pack('c', 128)], 'a', 'HS256'); + JWT::encode(['message' => pack('c', 128)], $this->hmacKey->getKeyMaterial(), 'HS256'); + } + + public function testInvalidKeyOpensslSignFail() + { + $this->expectException(DomainException::class); + JWT::sign('message', 'invalid key', 'openssl'); } public function testMalformedJsonThrowsException() @@ -36,143 +48,288 @@ public function testExpiredToken() { $this->expectException(ExpiredException::class); $payload = [ - "message" => "abc", - "exp" => time() - 20]; // time in the past - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + 'message' => 'abc', + 'exp' => time() - 20, // time in the past + ]; + + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); } public function testBeforeValidTokenWithNbf() { $this->expectException(BeforeValidException::class); $payload = [ - "message" => "abc", - "nbf" => time() + 20]; // time in the future - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + 'message' => 'abc', + 'nbf' => time() + 20, // time in the future + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); } public function testBeforeValidTokenWithIat() { $this->expectException(BeforeValidException::class); $payload = [ - "message" => "abc", - "iat" => time() + 20]; // time in the future - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + 'message' => 'abc', + 'iat' => time() + 20, // time in the future + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); } public function testValidToken() { $payload = [ - "message" => "abc", - "exp" => time() + JWT::$leeway + 20]; // time in the future - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); + 'message' => 'abc', + 'exp' => time() + JWT::$leeway + 20, // time in the future + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); + $this->assertSame($decoded->message, 'abc'); } + /** + * @runInSeparateProcess + */ public function testValidTokenWithLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", - "exp" => time() - 20]; // time in the past - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); - JWT::$leeway = 0; + 'message' => 'abc', + 'exp' => time() - 20, // time in the past + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); + $this->assertSame($decoded->message, 'abc'); } + /** + * @runInSeparateProcess + */ public function testExpiredTokenWithLeeway() { + $this->expectException(ExpiredException::class); JWT::$leeway = 60; $payload = [ - "message" => "abc", - "exp" => time() - 70]; // time far in the past + 'message' => 'abc', + 'exp' => time() - 70, // time far in the past + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); + $this->assertSame($decoded->message, 'abc'); + } + + public function testExpiredExceptionPayload() + { + $this->expectException(ExpiredException::class); + $payload = [ + 'message' => 'abc', + 'exp' => time() - 100, // time in the past + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + try { + JWT::decode($encoded, $this->hmacKey); + } catch (ExpiredException $e) { + $exceptionPayload = (array) $e->getPayload(); + $this->assertEquals($exceptionPayload, $payload); + throw $e; + } + } + + /** + * @runInSeparateProcess + */ + public function testExpiredExceptionTimestamp() + { $this->expectException(ExpiredException::class); - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); - JWT::$leeway = 0; + + JWT::$timestamp = 98765; + $payload = [ + 'message' => 'abc', + 'exp' => 1234, + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + + try { + JWT::decode($encoded, $this->hmacKey); + } catch (ExpiredException $e) { + $exTimestamp = $e->getTimestamp(); + $this->assertSame(98765, $exTimestamp); + throw $e; + } + } + + public function testBeforeValidExceptionPayload() + { + $this->expectException(BeforeValidException::class); + $payload = [ + 'message' => 'abc', + 'iat' => time() + 100, // time in the future + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + try { + JWT::decode($encoded, $this->hmacKey); + } catch (BeforeValidException $e) { + $exceptionPayload = (array) $e->getPayload(); + $this->assertEquals($exceptionPayload, $payload); + throw $e; + } } public function testValidTokenWithNbf() { $payload = [ - "message" => "abc", - "iat" => time(), - "exp" => time() + 20, // time in the future - "nbf" => time() - 20]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); + 'message' => 'abc', + 'iat' => time(), + 'exp' => time() + 20, // time in the future + 'nbf' => time() - 20 + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); + $this->assertSame($decoded->message, 'abc'); } + /** + * @runInSeparateProcess + */ public function testValidTokenWithNbfLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", - "nbf" => time() + 20]; // not before in near (leeway) future - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); - JWT::$leeway = 0; + 'message' => 'abc', + 'nbf' => time() + 20, // not before in near (leeway) future + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); + $this->assertSame($decoded->message, 'abc'); } + /** + * @runInSeparateProcess + */ public function testInvalidTokenWithNbfLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", - "nbf" => time() + 65]; // not before too far in future - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + 'message' => 'abc', + 'nbf' => time() + 65, // not before too far in future + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $this->expectException(BeforeValidException::class); + $this->expectExceptionMessage('Cannot handle token with nbf prior to'); + JWT::decode($encoded, $this->hmacKey); + } + + public function testValidTokenWithNbfIgnoresIat() + { + $payload = [ + 'message' => 'abc', + 'nbf' => time() - 20, // time in the future + 'iat' => time() + 20, // time in the past + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); + $this->assertEquals('abc', $decoded->message); + } + + public function testValidTokenWithNbfMicrotime() + { + $payload = [ + 'message' => 'abc', + 'nbf' => microtime(true), // use microtime + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); + $this->assertEquals('abc', $decoded->message); + } + + public function testInvalidTokenWithNbfMicrotime() + { $this->expectException(BeforeValidException::class); - JWT::decode($encoded, new Key('my_key', 'HS256')); - JWT::$leeway = 0; + $this->expectExceptionMessage('Cannot handle token with nbf prior to'); + $payload = [ + 'message' => 'abc', + 'nbf' => microtime(true) + 20, // use microtime in the future + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); } + /** + * @runInSeparateProcess + */ public function testValidTokenWithIatLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", - "iat" => time() + 20]; // issued in near (leeway) future - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); - JWT::$leeway = 0; + 'message' => 'abc', + 'iat' => time() + 20, // issued in near (leeway) future + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); + $this->assertSame($decoded->message, 'abc'); } + /** + * @runInSeparateProcess + */ public function testInvalidTokenWithIatLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", - "iat" => time() + 65]; // issued too far in future - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + 'message' => 'abc', + 'iat' => time() + 65, // issued too far in future + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $this->expectException(BeforeValidException::class); + $this->expectExceptionMessage('Cannot handle token with iat prior to'); + JWT::decode($encoded, $this->hmacKey); + } + + public function testValidTokenWithIatMicrotime() + { + $payload = [ + 'message' => 'abc', + 'iat' => microtime(true), // use microtime + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); + $this->assertEquals('abc', $decoded->message); + } + + public function testInvalidTokenWithIatMicrotime() + { $this->expectException(BeforeValidException::class); - JWT::decode($encoded, new Key('my_key', 'HS256')); - JWT::$leeway = 0; + $this->expectExceptionMessage('Cannot handle token with iat prior to'); + $payload = [ + 'message' => 'abc', + 'iat' => microtime(true) + 20, // use microtime in the future + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); } public function testInvalidToken() { + $encodeKey = $this->generateHmac256(); + $decodeKey = $this->generateHmac256(); $payload = [ - "message" => "abc", - "exp" => time() + 20]; // time in the future - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + 'message' => 'abc', + 'exp' => time() + 20, // time in the future + ]; + $encoded = JWT::encode($payload, $encodeKey->getKeyMaterial(), $encodeKey->getAlgorithm()); $this->expectException(SignatureInvalidException::class); - JWT::decode($encoded, new Key('my_key2', 'HS256')); + JWT::decode($encoded, $decodeKey); } public function testNullKeyFails() { $payload = [ - "message" => "abc", - "exp" => time() + JWT::$leeway + 20]; // time in the future - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + 'message' => 'abc', + 'exp' => time() + JWT::$leeway + 20, // time in the future + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(TypeError::class); JWT::decode($encoded, new Key(null, 'HS256')); } @@ -180,9 +337,10 @@ public function testNullKeyFails() public function testEmptyKeyFails() { $payload = [ - "message" => "abc", - "exp" => time() + JWT::$leeway + 20]; // time in the future - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + 'message' => 'abc', + 'exp' => time() + JWT::$leeway + 20, // time in the future + ]; + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(InvalidArgumentException::class); JWT::decode($encoded, new Key('', 'HS256')); } @@ -190,10 +348,11 @@ public function testEmptyKeyFails() public function testKIDChooser() { $keys = [ - '1' => new Key('my_key', 'HS256'), - '2' => new Key('my_key2', 'HS256') + '0' => $this->generateHmac256(), + '1' => $this->generateHmac256(), + '2' => $this->generateHmac256() ]; - $msg = JWT::encode(['message' => 'abc'], $keys['1']->getKeyMaterial(), 'HS256', '1'); + $msg = JWT::encode(['message' => 'abc'], $keys['0']->getKeyMaterial(), 'HS256', '0'); $decoded = JWT::decode($msg, $keys); $expected = new stdClass(); $expected->message = 'abc'; @@ -202,11 +361,12 @@ public function testKIDChooser() public function testArrayAccessKIDChooser() { - $keys = new ArrayObject([ - '1' => new Key('my_key', 'HS256'), - '2' => new Key('my_key2', 'HS256'), - ]); - $msg = JWT::encode(['message' => 'abc'], $keys['1']->getKeyMaterial(), 'HS256', '1'); + $keys = [ + '0' => $this->generateHmac256(), + '1' => $this->generateHmac256(), + '2' => $this->generateHmac256() + ]; + $msg = JWT::encode(['message' => 'abc'], $keys['0']->getKeyMaterial(), 'HS256', '0'); $decoded = JWT::decode($msg, $keys); $expected = new stdClass(); $expected->message = 'abc'; @@ -215,59 +375,62 @@ public function testArrayAccessKIDChooser() public function testNoneAlgorithm() { - $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $msg = JWT::encode(['message' => 'abc'], $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(UnexpectedValueException::class); - JWT::decode($msg, new Key('my_key', 'none')); + JWT::decode($msg, new Key($this->hmacKey->getKeyMaterial(), 'none')); } public function testIncorrectAlgorithm() { - $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $msg = JWT::encode(['message' => 'abc'], $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(UnexpectedValueException::class); - JWT::decode($msg, new Key('my_key', 'RS256')); + // TODO: Generate proper RS256 key + JWT::decode($msg, new Key($this->hmacKey->getKeyMaterial(), 'RS256')); } public function testEmptyAlgorithm() { - $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $msg = JWT::encode(['message' => 'abc'], $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(InvalidArgumentException::class); - JWT::decode($msg, new Key('my_key', '')); + JWT::decode($msg, new Key($this->hmacKey->getKeyMaterial(), '')); } public function testAdditionalHeaders() { - $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256', null, ['cty' => 'test-eit;v=1']); + $msg = JWT::encode(['message' => 'abc'], $this->hmacKey->getKeyMaterial(), 'HS256', null, ['cty' => 'test-eit;v=1']); $expected = new stdClass(); $expected->message = 'abc'; - $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), $expected); + $this->assertEquals(JWT::decode($msg, $this->hmacKey), $expected); } public function testInvalidSegmentCount() { $this->expectException(UnexpectedValueException::class); - JWT::decode('brokenheader.brokenbody', new Key('my_key', 'HS256')); + JWT::decode('brokenheader.brokenbody', $this->hmacKey); } public function testInvalidSignatureEncoding() { - $msg = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6ImZvbyJ9.Q4Kee9E8o0Xfo4ADXvYA8t7dN_X_bU9K5w6tXuiSjlUxx"; + $msg = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6ImZvbyJ9.Q4Kee9E8o0Xfo4ADXvYA8t7dN_X_bU9K5w6tXuiSjlUxx'; $this->expectException(UnexpectedValueException::class); - JWT::decode($msg, new Key('secret', 'HS256')); + JWT::decode($msg, $this->hmacKey); } public function testHSEncodeDecode() { - $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $msg = JWT::encode(['message' => 'abc'], $this->hmacKey->getKeyMaterial(), 'HS256'); $expected = new stdClass(); $expected->message = 'abc'; - $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), $expected); + $this->assertEquals(JWT::decode($msg, $this->hmacKey), $expected); } public function testRSEncodeDecode() { - $privKey = openssl_pkey_new(['digest_alg' => 'sha256', - 'private_key_bits' => 1024, - 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + $privKey = openssl_pkey_new([ + 'digest_alg' => 'sha256', + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA + ]); $msg = JWT::encode(['message' => 'abc'], $privKey, 'RS256'); $pubKey = openssl_pkey_get_details($privKey); $pubKey = $pubKey['key']; @@ -287,7 +450,7 @@ public function testEdDsaEncodeDecode() $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); $decoded = JWT::decode($msg, new Key($pubKey, 'EdDSA')); - $this->assertEquals('bar', $decoded->foo); + $this->assertSame('bar', $decoded->foo); } public function testInvalidEdDsaEncodeDecode() @@ -321,6 +484,24 @@ public function testRSEncodeDecodeWithPassphrase() $this->assertEquals($decoded, $expected); } + public function testDecodesEmptyArrayAsObject() + { + $key = 'yma6Hq4XQegCVND8ef23OYgxSrC3IKqk'; + $payload = []; + $jwt = JWT::encode($payload, $key, 'HS256'); + $decoded = JWT::decode($jwt, new Key($key, 'HS256')); + $this->assertEquals((object) $payload, $decoded); + } + + public function testDecodesArraysInJWTAsArray() + { + $key = 'yma6Hq4XQegCVND8ef23OYgxSrC3IKqk'; + $payload = ['foo' => [1, 2, 3]]; + $jwt = JWT::encode($payload, $key, 'HS256'); + $decoded = JWT::decode($jwt, new Key($key, 'HS256')); + $this->assertSame($payload['foo'], $decoded->foo); + } + /** * @runInSeparateProcess * @dataProvider provideEncodeDecode @@ -335,7 +516,7 @@ public function testEncodeDecode($privateKeyFile, $publicKeyFile, $alg) $publicKey = file_get_contents($publicKeyFile); $decoded = JWT::decode($encoded, new Key($publicKey, $alg)); - $this->assertEquals('bar', $decoded->foo); + $this->assertSame('bar', $decoded->foo); } public function provideEncodeDecode() @@ -345,21 +526,219 @@ public function provideEncodeDecode() [__DIR__ . '/data/ecdsa384-private.pem', __DIR__ . '/data/ecdsa384-public.pem', 'ES384'], [__DIR__ . '/data/rsa1-private.pem', __DIR__ . '/data/rsa1-public.pub', 'RS512'], [__DIR__ . '/data/ed25519-1.sec', __DIR__ . '/data/ed25519-1.pub', 'EdDSA'], + [__DIR__ . '/data/secp256k1-private.pem', __DIR__ . '/data/secp256k1-public.pem', 'ES256K'], ]; } - public function testEncodeDecodeWithResource() + public function testEncodeDecodeWithOpenSSLAsymmetricKey() { $pem = file_get_contents(__DIR__ . '/data/rsa1-public.pub'); - $resource = openssl_pkey_get_public($pem); + $keyMaterial = openssl_pkey_get_public($pem); $privateKey = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); $payload = ['foo' => 'bar']; $encoded = JWT::encode($payload, $privateKey, 'RS512'); // Verify decoding succeeds - $decoded = JWT::decode($encoded, new Key($resource, 'RS512')); + $decoded = JWT::decode($encoded, new Key($keyMaterial, 'RS512')); + + $this->assertSame('bar', $decoded->foo); + } + + public function testGetHeaders() + { + $payload = [ + 'message' => 'abc', + 'exp' => time() + JWT::$leeway + 20, // time in the future + ]; + $headers = new stdClass(); + + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey, $headers); + + $this->assertEquals($headers->typ, 'JWT'); + $this->assertEquals($headers->alg, 'HS256'); + } + + public function testAdditionalHeaderOverrides() + { + $msg = JWT::encode( + ['message' => 'abc'], + $this->hmacKey->getKeyMaterial(), + 'HS256', + 'my_key_id', + [ + 'cty' => 'test-eit;v=1', + 'typ' => 'JOSE', // override type header + 'kid' => 'not_my_key_id', // should not override $key param + 'alg' => 'BAD', // should not override $alg param + ] + ); + $headers = new stdClass(); + JWT::decode($msg, $this->hmacKey, $headers); + $this->assertEquals('test-eit;v=1', $headers->cty, 'additional field works'); + $this->assertEquals('JOSE', $headers->typ, 'typ override works'); + $this->assertEquals('my_key_id', $headers->kid, 'key param not overridden'); + $this->assertEquals('HS256', $headers->alg, 'alg param not overridden'); + } + + public function testDecodeExpectsIntegerIat() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Payload iat must be a number'); + + $payload = JWT::encode(['iat' => 'not-an-int'], $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($payload, $this->hmacKey); + } + + public function testDecodeExpectsIntegerNbf() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Payload nbf must be a number'); + + $payload = JWT::encode(['nbf' => 'not-an-int'], $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($payload, $this->hmacKey); + } + + public function testDecodeExpectsIntegerExp() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Payload exp must be a number'); + + $payload = JWT::encode(['exp' => 'not-an-int'], $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($payload, $this->hmacKey); + } + + public function testRsaKeyLengthValidationThrowsException(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Provided key is too short'); + + // Generate an RSA key that is smaller than the 2048-bit minimum + $shortRsaKey = openssl_pkey_new([ + 'private_key_bits' => 1024, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + self::assertNotFalse($shortRsaKey, 'Failed to generate a short RSA key for testing.'); + $payload = ['message' => 'abc']; + JWT::encode($payload, $shortRsaKey, 'RS256'); + } + + /** @dataProvider provideHmac */ + public function testHmacKeyLengthValidationThrowsExceptionEncode(string $alg, int $minLength): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Provided key is too short'); - $this->assertEquals('bar', $decoded->foo); + $tooShortKeyBytes = str_repeat('b', $minLength - 1); + $payload = ['message' => 'abc']; + + JWT::encode($payload, $tooShortKeyBytes, $alg); + } + + /** @dataProvider provideHmac */ + public function testHmacKeyLengthValidationThrowsExceptionDecode(string $alg, int $minLength): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Provided key is too short'); + + $tooShortKeyBytes = str_repeat('b', $minLength - 1); + $payload = ['message' => 'abc']; + + $validKeyBytes = str_repeat('b', $minLength); + $encoded = JWT::encode($payload, $validKeyBytes, $alg); + + JWT::decode($encoded, new Key($tooShortKeyBytes, $alg)); + } + + /** @dataProvider provideHmac */ + public function testHmacKeyLengthValidationPassesWithCorrectLength(string $alg, int $minLength): void + { + $payload = ['message' => 'test hmac length']; + + // Test with a key that is exactly the required length + $minKeyBytes = str_repeat('b', $minLength); + $encoded48 = JWT::encode($payload, $minKeyBytes, $alg); + $decoded48 = JWT::decode($encoded48, new Key($minKeyBytes, $alg)); + $this->assertEquals($payload['message'], $decoded48->message); + + // Test with a key that is longer than the required length + $largeKeyBytes = str_repeat('c', $minLength * 2); // Longer than min bytes + $encoded64 = JWT::encode($payload, $largeKeyBytes, $alg); + $decoded64 = JWT::decode($encoded64, new Key($largeKeyBytes, $alg)); + $this->assertEquals($payload['message'], $decoded64->message); + } + + public function provideHmac() + { + return [ + ['HS384', 48], + ['HS256', 32], + ]; + } + + /** @dataProvider provideEcKeyInvalidLength */ + public function testEcKeyLengthValidationThrowsExceptionEncode(string $keyFile, string $alg): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Provided key is too short'); + + $tooShortEcKey = file_get_contents(__DIR__ . '/data/' . $keyFile); + $payload = ['message' => 'abc']; + + JWT::encode($payload, $tooShortEcKey, $alg); + } + + public function testEcKeyLengthValidationThrowsExceptionDecode(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Provided key is too short'); + + $payload = ['message' => 'abc']; + + $validEcKeyBytes = file_get_contents(__DIR__ . '/data/ecdsa384-private.pem'); + $encoded = JWT::encode($payload, $validEcKeyBytes, 'ES256'); + + $tooShortEcKey = file_get_contents(__DIR__ . '/data/ecdsa192-public.pem'); + JWT::decode($encoded, new Key($tooShortEcKey, 'ES256')); + } + + /** @dataProvider provideEcKey */ + public function testEcKeyLengthValidationPassesWithCorrectLength( + string $privateKeyFile, + string $publicKeyFile, + string $alg + ): void { + $payload = ['message' => 'test hmac length']; + + // Test with a key that is the required length + $privateKeyBytes = file_get_contents(__DIR__ . '/data/' . $privateKeyFile); + $encoded48 = JWT::encode($payload, $privateKeyBytes, $alg); + + $publicKeyBytes = file_get_contents(__DIR__ . '/data/' . $publicKeyFile); + $decoded48 = JWT::decode($encoded48, new Key($publicKeyBytes, $alg)); + $this->assertEquals($payload['message'], $decoded48->message); + } + + public function provideEcKeyInvalidLength() + { + return [ + ['ecdsa192-private.pem', 'ES256'], + ['ecdsa-private.pem', 'ES384'], + ]; + } + + public function provideEcKey() + { + return [ + ['ecdsa-private.pem', 'ecdsa-public.pem', 'ES256'], + ['ecdsa384-private.pem', 'ecdsa384-public.pem', 'ES384'], + ]; + } + + private function generateHmac256(): Key + { + return new Key(random_bytes(32), 'HS256'); } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index 385b6706..00000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,14 +0,0 @@ -