From 8ddb39535ef82b835e39fe8f5ad3c5bd452a0148 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 17 May 2021 11:37:21 -0500 Subject: [PATCH 001/121] chore: remove travis, add github actions (#331) --- .github/actions/entrypoint.sh | 18 +++++++++ .github/workflows/tests.yml | 66 +++++++++++++++++++++++++++++++ .travis.yml | 36 ----------------- phpunit.xml.dist | 1 - src/BeforeValidException.php | 1 + src/ExpiredException.php | 1 + src/SignatureInvalidException.php | 1 + tests/JWKTest.php | 1 + tests/JWTTest.php | 1 + 9 files changed, 89 insertions(+), 37 deletions(-) create mode 100755 .github/actions/entrypoint.sh create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml diff --git a/.github/actions/entrypoint.sh b/.github/actions/entrypoint.sh new file mode 100755 index 00000000..ce8379cb --- /dev/null +++ b/.github/actions/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh -l + +apt-get update && \ +apt-get install -y --no-install-recommends \ + git \ + zip \ + curl \ + unzip \ + wget + +curl --silent --show-error https://getcomposer.org/installer | php +php composer.phar self-update + +echo "---Installing dependencies ---" +php composer.phar update + +echo "---Running unit tests ---" +vendor/bin/phpunit diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..09539931 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,66 @@ +name: Test Suite +on: + push: + branches: + - master + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + php: [ "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0" ] + name: PHP ${{matrix.php }} Unit Test + steps: + - uses: actions/checkout@v2 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + - name: Install Dependencies + uses: nick-invision/retry@v1 + with: + timeout_minutes: 10 + max_attempts: 3 + command: composer install + - name: Run Script + run: vendor/bin/phpunit + + # use dockerfiles for old versions of php (setup-php times out for those). + test_php55: + name: "PHP 5.5 Unit Test" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Run Unit Tests + uses: docker://php:5.5-cli + with: + entrypoint: ./.github/actions/entrypoint.sh + + test_php54: + name: "PHP 5.4 Unit Test" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Run Unit Tests + uses: docker://php:5.4-cli + with: + entrypoint: ./.github/actions/entrypoint.sh + + style: + runs-on: ubuntu-latest + name: PHP Style Check + steps: + - uses: actions/checkout@v2 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "7.0" + - 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 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 90e516cd..00000000 --- a/.travis.yml +++ /dev/null @@ -1,36 +0,0 @@ -language: php - -branches: - - only: [master] - -php: - - 5.6 - - 7.0 - - 7.1 - - 7.2 - - 7.3 - - 7.4 - -matrix: - include: - - php: 5.3 - dist: precise - - php: 5.4 - dist: trusty - - php: 5.5 - dist: trusty - - name: "Check Style" - php: "7.4" - env: RUN_CS_FIXER=true - -sudo: false - -before_script: composer install -script: - - if [ "${RUN_CS_FIXER}" = "true" ]; then - 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; - else - vendor/bin/phpunit; - fi diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9f85f5ba..092a662c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,7 +8,6 @@ convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" - syntaxCheck="false" bootstrap="tests/bootstrap.php" > diff --git a/src/BeforeValidException.php b/src/BeforeValidException.php index fdf82bd9..c147852b 100644 --- a/src/BeforeValidException.php +++ b/src/BeforeValidException.php @@ -1,4 +1,5 @@ Date: Mon, 17 May 2021 11:49:46 -0500 Subject: [PATCH 002/121] fix: allow for null d values in RSA JWK (#330) --- src/JWK.php | 2 +- tests/JWKTest.php | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/JWK.php b/src/JWK.php index 7632f4a4..29dbbac1 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -82,7 +82,7 @@ private static function parseKey(array $jwk) switch ($jwk['kty']) { case 'RSA': - if (\array_key_exists('d', $jwk)) { + if (!empty($jwk['d'])) { throw new UnexpectedValueException('RSA private keys are not supported'); } if (!isset($jwk['n']) || !isset($jwk['e'])) { diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 93572400..0709836d 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -32,6 +32,36 @@ public function testInvalidAlgorithm() $keys = JWK::parseKeySet(array('keys' => array($badJwk))); } + public function testParsePrivateKey() + { + $this->setExpectedException( + 'UnexpectedValueException', + 'RSA private keys are not supported' + ); + + $jwkSet = json_decode( + file_get_contents(__DIR__ . '/rsa-jwkset.json'), + true + ); + $jwkSet['keys'][0]['d'] = 'privatekeyvalue'; + + JWK::parseKeySet($jwkSet); + } + + public function testParseKeyWithEmptyDValue() + { + $jwkSet = json_decode( + file_get_contents(__DIR__ . '/rsa-jwkset.json'), + true + ); + + // empty or null values are ok + $jwkSet['keys'][0]['d'] = null; + + $keys = JWK::parseKeySet($jwkSet); + $this->assertTrue(is_array($keys)); + } + public function testParseJwkKeySet() { $jwkSet = json_decode( From fd08d5a171cad8ba519ab4aef9a4e2497ee0e109 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 17 May 2021 17:50:39 -0500 Subject: [PATCH 003/121] chore(docs): clean up README (#332) --- README.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ba139079..9f3d0846 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,7 @@ composer require firebase/php-jwt Example ------- ```php - ``` Example with RS256 (openssl) ---------------------------- ```php - ``` Using JWKs ---------- ```php +use Firebase\JWT\JWK; +use Firebase\JWT\JWT; + // Set of keys. The "keys" key is required. For example, the JSON response to // this endpoint: https://www.gstatic.com/iap/verify/public_key-jwk $jwks = ['keys' => []]; From 75693c0d2aa03489db05f42b2791dd81fe3f1164 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 17 May 2021 15:57:50 -0700 Subject: [PATCH 004/121] chore(tests): add php 5.3 test --- .github/workflows/tests.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 09539931..0ea47358 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,6 +50,17 @@ jobs: with: entrypoint: ./.github/actions/entrypoint.sh + test_php53: + name: "PHP 5.3 Unit Test" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Run Unit Tests + uses: docker://php:5.3-cli + with: + entrypoint: ./.github/actions/entrypoint.sh + style: runs-on: ubuntu-latest name: PHP Style Check From d87688f9e65ca279ca66181fd3476782efb791d2 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 17 May 2021 18:35:58 -0500 Subject: [PATCH 005/121] chore(tests): fix entrypoint for php 5.3 (#333) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0ea47358..343df75d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,7 +57,7 @@ jobs: - name: Checkout uses: actions/checkout@v2 - name: Run Unit Tests - uses: docker://php:5.3-cli + uses: docker://tomsowerby/php-5.3:cli with: entrypoint: ./.github/actions/entrypoint.sh From 8d6bfd4e805a5fc323f18852aa253f4625ab212e Mon Sep 17 00:00:00 2001 From: Scott Dutton Date: Wed, 19 May 2021 19:08:37 +0100 Subject: [PATCH 006/121] chore: fix licence (#329) --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index cb0c49b3..11c01466 100644 --- a/LICENSE +++ b/LICENSE @@ -13,7 +13,7 @@ modification, are permitted provided that the following conditions are met: disclaimer in the documentation and/or other materials provided with the distribution. - * Neither the name of Neuman Vong nor the names of other + * Neither the name of the copyright holder nor the names of other contributors may be used to endorse or promote products derived from this software without specific prior written permission. From 9af3b99c1c92f20efd3f0a665f6e54aa5897ab89 Mon Sep 17 00:00:00 2001 From: Stoian Ivanov Date: Wed, 19 May 2021 21:19:30 +0300 Subject: [PATCH 007/121] feat: add ES384 support (#324) --- src/JWT.php | 17 ++++++++++++----- tests/JWTTest.php | 16 ++++++++++++++++ tests/ecdsa384-private.pem | 6 ++++++ tests/ecdsa384-public.pem | 5 +++++ 4 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 tests/ecdsa384-private.pem create mode 100644 tests/ecdsa384-public.pem diff --git a/src/JWT.php b/src/JWT.php index b167abd7..c68d4e15 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -42,6 +42,7 @@ class JWT public static $timestamp = null; public static $supported_algs = array( + 'ES384' => array('openssl', 'SHA384'), 'ES256' => array('openssl', 'SHA256'), 'HS256' => array('hash_hmac', 'SHA256'), 'HS384' => array('hash_hmac', 'SHA384'), @@ -58,7 +59,8 @@ class JWT * @param string|array|resource $key The key, or map of keys. * If the algorithm used is asymmetric, this is the public key * @param array $allowed_algs List of supported verification algorithms - * Supported algorithms are 'ES256', 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' + * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', + * 'HS512', 'RS256', 'RS384', and 'RS512' * * @return object The JWT's payload as a PHP object * @@ -102,8 +104,8 @@ public static function decode($jwt, $key, array $allowed_algs = array()) if (!\in_array($header->alg, $allowed_algs)) { throw new UnexpectedValueException('Algorithm not allowed'); } - if ($header->alg === 'ES256') { - // OpenSSL expects an ASN.1 DER sequence for ES256 signatures + if ($header->alg === 'ES256' || $header->alg === 'ES384') { + // OpenSSL expects an ASN.1 DER sequence for ES256/ES384 signatures $sig = self::signatureToDER($sig); } @@ -155,7 +157,8 @@ public static function decode($jwt, $key, array $allowed_algs = array()) * @param string $key The secret key. * If the algorithm used is asymmetric, this is the private key * @param string $alg The signing algorithm. - * Supported algorithms are 'ES256', 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' + * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', + * 'HS512', 'RS256', 'RS384', and 'RS512' * @param mixed $keyId * @param array $head An array with header elements to attach * @@ -190,7 +193,8 @@ public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $he * @param string $msg The message to sign * @param string|resource $key The secret key * @param string $alg The signing algorithm. - * Supported algorithms are 'ES256', 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' + * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', + * 'HS512', 'RS256', 'RS384', and 'RS512' * * @return string An encrypted message * @@ -214,6 +218,9 @@ public static function sign($msg, $key, $alg = 'HS256') if ($alg === 'ES256') { $signature = self::signatureFromDER($signature, 256); } + if ($alg === 'ES384') { + $signature = self::signatureFromDER($signature, 384); + } return $signature; } } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index bc9d7a8c..2516ec0d 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -300,4 +300,20 @@ public function testEncodeAndDecodeEcdsaToken() $this->assertEquals('bar', $decoded->foo); } + + /** + * @runInSeparateProcess + */ + public function testEncodeAndDecodeEcdsa384Token() + { + $privateKey = file_get_contents(__DIR__ . '/ecdsa384-private.pem'); + $payload = array('foo' => 'bar'); + $encoded = JWT::encode($payload, $privateKey, 'ES384'); + + // Verify decoding succeeds + $publicKey = file_get_contents(__DIR__ . '/ecdsa384-public.pem'); + $decoded = JWT::decode($encoded, $publicKey, array('ES384')); + + $this->assertEquals('bar', $decoded->foo); + } } diff --git a/tests/ecdsa384-private.pem b/tests/ecdsa384-private.pem new file mode 100644 index 00000000..ee593e6f --- /dev/null +++ b/tests/ecdsa384-private.pem @@ -0,0 +1,6 @@ +-----BEGIN EC PRIVATE KEY----- +MIGkAgEBBDBQJuwafREZ1494Fm2MTVXuZbWXVAOwIAxGhyLdc3CChzi0FVXZq8e6 +65oR0Qq9Jv2gBwYFK4EEACKhZANiAAQWFddzIqZaROR1VtZhhTd20mqknQmYsZ+0 +R03NQQUQpJTkyWcuv8WNyd6zO9cCoQEzi94kX907/OEWTjhuH8QtdunT+ef1BpWJ +W1Cm5O+m7b155/Ho99QypfQr74hLg1A= +-----END EC PRIVATE KEY----- diff --git a/tests/ecdsa384-public.pem b/tests/ecdsa384-public.pem new file mode 100644 index 00000000..475f1348 --- /dev/null +++ b/tests/ecdsa384-public.pem @@ -0,0 +1,5 @@ +-----BEGIN PUBLIC KEY----- +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEFhXXcyKmWkTkdVbWYYU3dtJqpJ0JmLGf +tEdNzUEFEKSU5MlnLr/FjcneszvXAqEBM4veJF/dO/zhFk44bh/ELXbp0/nn9QaV +iVtQpuTvpu29eefx6PfUMqX0K++IS4NQ +-----END PUBLIC KEY----- From ae3188c2077d3be2d1a45d8f7fcd83bdc7394b54 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 20 May 2021 11:48:20 -0500 Subject: [PATCH 008/121] chore: test cleanup (#334) --- tests/JWTTest.php | 63 +++++++++++++++++++++-------------------------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 2516ec0d..46ea7ef0 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -19,12 +19,6 @@ public function setExpectedException($exceptionName, $message = '', $code = null } } - public function testEncodeDecode() - { - $msg = JWT::encode('abc', 'my_key'); - $this->assertEquals(JWT::decode($msg, 'my_key', array('HS256')), 'abc'); - } - public function testDecodeFromPython() { $msg = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.Iio6aHR0cDovL2FwcGxpY2F0aW9uL2NsaWNreT9ibGFoPTEuMjMmZi5vbz00NTYgQUMwMDAgMTIzIg.E_U8X2YpMT5K1cEiT_3-IvBYfrdIFIeVYeOqre_Z5Cg'; @@ -217,18 +211,6 @@ public function testEmptyKeyFails() JWT::decode($encoded, '', array('HS256')); } - public function testRSEncodeDecode() - { - $privKey = openssl_pkey_new(array('digest_alg' => 'sha256', - 'private_key_bits' => 1024, - 'private_key_type' => OPENSSL_KEYTYPE_RSA)); - $msg = JWT::encode('abc', $privKey, 'RS256'); - $pubKey = openssl_pkey_get_details($privKey); - $pubKey = $pubKey['key']; - $decoded = JWT::decode($msg, $pubKey, array('RS256')); - $this->assertEquals($decoded, 'abc'); - } - public function testKIDChooser() { $keys = array('1' => 'my_key', '2' => 'my_key2'); @@ -285,35 +267,46 @@ public function testInvalidSignatureEncoding() JWT::decode($msg, 'secret', array('HS256')); } - /** - * @runInSeparateProcess - */ - public function testEncodeAndDecodeEcdsaToken() + public function testHSEncodeDecode() { - $privateKey = file_get_contents(__DIR__ . '/ecdsa-private.pem'); - $payload = array('foo' => 'bar'); - $encoded = JWT::encode($payload, $privateKey, 'ES256'); - - // Verify decoding succeeds - $publicKey = file_get_contents(__DIR__ . '/ecdsa-public.pem'); - $decoded = JWT::decode($encoded, $publicKey, array('ES256')); + $msg = JWT::encode('abc', 'my_key'); + $this->assertEquals(JWT::decode($msg, 'my_key', array('HS256')), 'abc'); + } - $this->assertEquals('bar', $decoded->foo); + public function testRSEncodeDecode() + { + $privKey = openssl_pkey_new(array('digest_alg' => 'sha256', + 'private_key_bits' => 1024, + 'private_key_type' => OPENSSL_KEYTYPE_RSA)); + $msg = JWT::encode('abc', $privKey, 'RS256'); + $pubKey = openssl_pkey_get_details($privKey); + $pubKey = $pubKey['key']; + $decoded = JWT::decode($msg, $pubKey, array('RS256')); + $this->assertEquals($decoded, 'abc'); } /** * @runInSeparateProcess + * @dataProvider provideEncodeDecode */ - public function testEncodeAndDecodeEcdsa384Token() + public function testEncodeDecode($privateKeyFile, $publicKeyFile, $alg) { - $privateKey = file_get_contents(__DIR__ . '/ecdsa384-private.pem'); + $privateKey = file_get_contents($privateKeyFile); $payload = array('foo' => 'bar'); - $encoded = JWT::encode($payload, $privateKey, 'ES384'); + $encoded = JWT::encode($payload, $privateKey, $alg); // Verify decoding succeeds - $publicKey = file_get_contents(__DIR__ . '/ecdsa384-public.pem'); - $decoded = JWT::decode($encoded, $publicKey, array('ES384')); + $publicKey = file_get_contents($publicKeyFile); + $decoded = JWT::decode($encoded, $publicKey, array($alg)); $this->assertEquals('bar', $decoded->foo); } + + public function provideEncodeDecode() + { + return array( + array(__DIR__ . '/ecdsa-private.pem', __DIR__ . '/ecdsa-public.pem', 'ES256'), + array(__DIR__ . '/ecdsa384-private.pem', __DIR__ . '/ecdsa384-public.pem', 'ES384'), + ); + } } From 3c2d70f2e64e2922345e89f2ceae47d2463faae1 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 20 May 2021 12:37:02 -0500 Subject: [PATCH 009/121] chore: add tests and docs for rsa with passphrase (#335) --- README.md | 34 ++++++++++++++++++++++++++++ src/JWT.php | 16 ++++++------- tests/JWTTest.php | 14 ++++++++++++ tests/rsa-with-passphrase.pem | 42 +++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 tests/rsa-with-passphrase.pem diff --git a/README.md b/README.md index 9f3d0846..66d7d014 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,40 @@ $decoded_array = (array) $decoded; echo "Decode:\n" . print_r($decoded_array, true) . "\n"; ``` +Example with a passphrase +------------------------- + +```php +// Your passphrase +$passphrase = '[YOUR_PASSPHRASE]'; + +// Your private key file with 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" +$privateKey = openssl_pkey_get_private( + file_get_contents($privateKeyFile), + $passphrase +); + +$payload = array( + "iss" => "example.org", + "aud" => "example.com", + "iat" => 1356999524, + "nbf" => 1357000000 +); + +$jwt = JWT::encode($payload, $privateKey, 'RS256'); +echo "Encode:\n" . print_r($jwt, true) . "\n"; + +// Get public key from the private key, or pull from from a file. +$publicKey = openssl_pkey_get_details($privateKey)['key']; + +$decoded = JWT::decode($jwt, $publicKey, array('RS256')); +echo "Decode:\n" . print_r((array) $decoded, true) . "\n"; +``` + Using JWKs ---------- diff --git a/src/JWT.php b/src/JWT.php index c68d4e15..4b85699f 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -153,14 +153,14 @@ public static function decode($jwt, $key, array $allowed_algs = array()) /** * Converts and signs a PHP object or array into a JWT string. * - * @param object|array $payload PHP object or array - * @param string $key The secret key. - * If the algorithm used is asymmetric, this is the private key - * @param string $alg The signing algorithm. - * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', - * 'HS512', 'RS256', 'RS384', and 'RS512' - * @param mixed $keyId - * @param array $head An array with header elements to attach + * @param object|array $payload PHP object or array + * @param string|resource $key The secret key. + * If the algorithm used is asymmetric, this is the private key + * @param string $alg The signing algorithm. + * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', + * 'HS512', 'RS256', 'RS384', and 'RS512' + * @param mixed $keyId + * @param array $head An array with header elements to attach * * @return string A signed JWT * diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 46ea7ef0..7c3d9649 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -285,6 +285,20 @@ public function testRSEncodeDecode() $this->assertEquals($decoded, 'abc'); } + public function testRSEncodeDecodeWithPassphrase() + { + $privateKey = openssl_pkey_get_private( + file_get_contents(__DIR__ . '/rsa-with-passphrase.pem'), + 'passphrase' + ); + + $jwt = JWT::encode('abc', $privateKey, 'RS256'); + $keyDetails = openssl_pkey_get_details($privateKey); + $pubKey = $keyDetails['key']; + $decoded = JWT::decode($jwt, $pubKey, array('RS256')); + $this->assertEquals($decoded, 'abc'); + } + /** * @runInSeparateProcess * @dataProvider provideEncodeDecode diff --git a/tests/rsa-with-passphrase.pem b/tests/rsa-with-passphrase.pem new file mode 100644 index 00000000..ad55326f --- /dev/null +++ b/tests/rsa-with-passphrase.pem @@ -0,0 +1,42 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-128-CBC,D8AA4EC8D8B5883F09ACB308FB026C94 + +ixqo1+NTlkiUHUa1bucqHNQ4nca4cnaosK8Lauftc0WuyqNVE+NL/zxdiUKN+Qi1 +bhEkvMKgbqTMzPFUws3wNoPEI/eaoGYHTl4nAX79JWjJ8/DWY+VVp5IFSzNEM1MP +NMWaivfBGhd8W9kBmpOJpQjwePFk7hdLkEvSngGRhDmEV046cWr7I+koYKEG/oW9 +53NnDNKPKLPkzM/Me4GQ6nXarqUPoIn/c3qFLgkhkzLJ/Lu21wnYx46RasXJv3oq +xT3nRIat/Q6jtlzLLwvo+lpvJW3G+rKqjEi76Av7Cm1TkHQFW9CGsnQ4ZDn427KL +FGojP6QG5RjLI6IiAHgt0lnzOwtjbF1RQBHIWedC4Rufb5u92SRKJ2PvidB/suJ7 +SR/PPA2XpK22QBMccO9yjNh4ZZIV6I2cqv3BlKR2RFU0552sEQr6usxPfFhExIRR +1eiaLtIupo3uEC5e2fBKtI7D3T7WztUagTw0vSgoxhTdc8XIoT0prV91SvyEEZMw +r5LSRW4BvyCekG9FFyIS2fOWabgxmm16siNErTbS2RS3GGimX0v5O+KIN9ho1uAY +5U865amaOZshop1YYixtDJL27JhpkODhwXrB1lNQOCdi64CV2r8VlVPNg6TWZlli +vJ6agKvWmTppy07ovbBRB+llmW6eGtjwEmAvMaWNgkFNkgDF/wBnDi91tx8/8UL7 +XQy0VZz128FtpJC0G0Z/5HmxqoEJAwk1+EzO5tgnfc+2wIONGCV2ISph0efVtPui +xOP6geaeSrxBxL/BUcIX5DMfN6hsvz+Pb8bE9WT2+fz/ySCJhkfraC/vHbs3wn3R +CICCvYtR803ku53GCgsEZ8vmIxMb1D0mJnfWvSQtDBqF8XwhL6m5ShbeaMLkbmZ9 +0WLWj0zAcOkbX4TXLGVaRPRs9HjSEr7+jEVHO6OeKj60rG9M3NVmfig7J8ta/zvy +1Hk4MiucTsp0I+G/hx8dqoV4x1kTyn0WZMfD8PxnbPdPvbhG2tQn7xkZykgtvK5y +s1fMbvqVGDfn5PmLeSwYkyohYZGbiwV5UldhwdG/ZnagI1KPuJ10OYBOSLCcGufY +aUHmIFSvfYqbN5YfKsMCZmmrX73pDcXOWGWto8nTFS9f4RlQI0Vh25xJqqinD6Vu +ErP7+XxDZCLqKew/xfq1fcKoiCOA/9IK5meyjRV4Z5QxkgTeBmyNVt/MW+6QIJJJ +WoBWqpootxtb28YN2RuD0byEIyP8pmoyN3MOPYGNSia8PAQgIL6z71Ju2SejXADy +ybirbrS0Y/oZABqhLK5qDdCYe4O5zp/lbwWn2Gfp3G3xKUxfBWi4f/VQwUjUbYCz +XHFVLpDY1mMPaedo7Tp5ZGN4OHwIlpspcwI0U9TYac0AxZuSBPjE8YqJ2qJBhaiZ +dEE7CxwkSLLxXVEPp7+VO6CORZfYXXaRcpTAZfrDURSI5RkT8n6LElnrzFBilb0q +ejlKaLD4MLlvlc/NWl/w+TfuN/iGlQm02Ul8yysG1b0w8R+seMNHhHS4+848ZRBd +HoWUuYiYXZTJxmP5dc0f/Sul672YSFp7rGzt9+7hFV6WrkAFNxETkQ8cbA/GiGvz +Kvv1GI/Ms8YymAJWiv7skFTmGcHMbjxga2EOBtSfYF5mwV3KEMPRpYsn1nw6U99E +NuWFqT+p4VqVSgmeG11zwM7v+Vt3RZDUggZWDsNKGA9V9ciAlHY2U7CH6xihBCfh +suHNuzVC1nAwi/ZrhfJXMKk+hJ8o+5dXSTYp4eCEGh4U2l3pmmAejZenJqlGs0Ke +MYHQRCk5zaB5myRYuvwtUSbZ/BaVVFSQz758Vw4HxKFLnvudtAXktu3sTcOgYKQS +PaiolwZFr4lp3h74BlIYcYrREmBJv6Hy1lOLAd5X3iExiy+DdRJWkuNd+19Cblq3 +ePHf2Mgp+AElxmyA6EHyt86v3E2mL7xNAUUVrNb3UJTi6io5KASMVmNbrGGJksC7 +y3OuHaq1RM7UvR/eI38nI2YOckoKDgkhHPtaXkIpO9jX3RRYlA2uzsf44DU7etyc +c2ICApYVdKruR/pmFN45pcIPy6x3zU34fkRTMf1F3yShJzr8Ntd/C63Km8XaganW +2AVWuuvOJXjMqu4+OXzrIqObFFp6naqv1E+O8/14i8k4VW3dmWnMM7eq9FvqQdiM +y0tBbGILfAVYtjh59r+CKeqRoq7o/xlsVin1Vxn74K6uYUphjXWUhMXXStGZ8sBc +QDOPTanB+LPBeCAgQFQe1SHrGiIognXT0g2WFqW8DrxwTqr6olPoMF6LU01vqT0+ +HVZtczjk0LvDLZm8bsCDGBPDdbDI/tfvXncP5PgEtFSTUiRy+zryy82AF4rJhudH +-----END RSA PRIVATE KEY----- From c63c1a713348bc2b3c5fd2bab9d078bd702bbaa9 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 22 Jun 2021 13:05:02 -0500 Subject: [PATCH 010/121] feat: make parseKey public (#337) --- src/JWK.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JWK.php b/src/JWK.php index 29dbbac1..981a9ba7 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -71,7 +71,7 @@ public static function parseKeySet(array $jwks) * * @uses createPemFromModulusAndExponent */ - private static function parseKey(array $jwk) + public static function parseKey(array $jwk) { if (empty($jwk)) { throw new InvalidArgumentException('JWK must not be empty'); From 21b0c7bfb71a8b9b3ffae5acc2a0d6a38eb5ae75 Mon Sep 17 00:00:00 2001 From: Romain Menke <11521496+romainmenke@users.noreply.github.com> Date: Wed, 23 Jun 2021 18:25:50 +0200 Subject: [PATCH 011/121] chore: export-ignore github dir (#338) --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index a791521e..6d63e560 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,4 @@ /.gitignore export-ignore /.travis.yml export-ignore /phpunit.xml.dist export-ignore +/.github export-ignore From 44d0a5a17312b55e26b78ad87b034be71495b137 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 23 Jun 2021 11:39:56 -0500 Subject: [PATCH 012/121] chore(tests): add rsa encode/decode (#344) --- tests/JWTTest.php | 1 + tests/rsa1-public.pub | 9 +++++++++ 2 files changed, 10 insertions(+) create mode 100644 tests/rsa1-public.pub diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 7c3d9649..09dac142 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -321,6 +321,7 @@ public function provideEncodeDecode() return array( array(__DIR__ . '/ecdsa-private.pem', __DIR__ . '/ecdsa-public.pem', 'ES256'), array(__DIR__ . '/ecdsa384-private.pem', __DIR__ . '/ecdsa384-public.pem', 'ES384'), + array(__DIR__ . '/rsa1-private.pem', __DIR__ . '/rsa1-public.pub', 'RS512'), ); } } diff --git a/tests/rsa1-public.pub b/tests/rsa1-public.pub new file mode 100644 index 00000000..83a080f4 --- /dev/null +++ b/tests/rsa1-public.pub @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Ttga33B1yX4w77NbpKy +NYDNSVCo8j+RlZaZ9tI+KfkV1d+tfsvI9ZPAheP11FoN52ceBaY5ltelHW+IKwCf +yT0orLdsxLgowaXki9woF1Azvcg2JVxQLv9aVjjAvy3CZFIG/EeN7J3nsyCXGnu1 +yMEbnvkWxA88//Q6HQ2K9wqfApkQ0LNlsK0YHz/sfjHNvRKxnbAJk7D5fUhZunPZ +XOPHXFgA5SvLvMaNIXduMKJh4OMfuoLdJowXJAR9j31Mqz/is4FMhm/9Mq7vZZ+u +F09htRvIR8tRY28oJuW1gKWyg7cQQpnjHgFyG3XLXWAeXclWqyh/LfjyHQjrYhye +FwIDAQAB +-----END PUBLIC KEY----- From d2113d9b2e0e349796e72d2a63cf9319100382d2 Mon Sep 17 00:00:00 2001 From: pwolanin Date: Wed, 23 Jun 2021 15:00:23 -0400 Subject: [PATCH 013/121] feat: add Ed25519 support to JWT (#343) --- .github/actions/entrypoint.sh | 4 +++- .github/workflows/tests.yml | 2 ++ README.md | 37 +++++++++++++++++++++++++++++ composer.json | 3 +++ src/JWT.php | 44 +++++++++++++++++++++++++++-------- tests/JWTTest.php | 29 +++++++++++++++++++++++ tests/ed25519-1.pub | 1 + tests/ed25519-1.sec | 1 + 8 files changed, 110 insertions(+), 11 deletions(-) create mode 100644 tests/ed25519-1.pub create mode 100644 tests/ed25519-1.sec diff --git a/.github/actions/entrypoint.sh b/.github/actions/entrypoint.sh index ce8379cb..8b6b9e1b 100755 --- a/.github/actions/entrypoint.sh +++ b/.github/actions/entrypoint.sh @@ -12,7 +12,9 @@ curl --silent --show-error https://getcomposer.org/installer | php php composer.phar self-update echo "---Installing dependencies ---" -php composer.phar update + +# 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/tests.yml b/.github/workflows/tests.yml index 343df75d..92b4e9e0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,6 +24,8 @@ jobs: timeout_minutes: 10 max_attempts: 3 command: composer install + - if: ${{ matrix.php == '5.6' }} + run: composer require --dev --with-dependencies paragonie/sodium_compat - name: Run Script run: vendor/bin/phpunit diff --git a/README.md b/README.md index 66d7d014..a8556aa5 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ Use composer to manage your dependencies and download PHP-JWT: 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: + +```bash +composer require paragonie/sodium_compat +``` + Example ------- ```php @@ -144,6 +151,36 @@ $decoded = JWT::decode($jwt, $publicKey, array('RS256')); echo "Decode:\n" . print_r((array) $decoded, true) . "\n"; ``` +Example with EdDSA (libsodium and Ed25519 signature) +---------------------------- +```php +use Firebase\JWT\JWT; + +// Public and private keys are expected to be Base64 encoded. The last +// non-empty line is used so that keys can be generated with +// sodium_crypto_sign_keypair(). The secret keys generated by other tools may +// need to be adjusted to match the input expected by libsodium. + +$keyPair = sodium_crypto_sign_keypair(); + +$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 +); + +$jwt = JWT::encode($payload, $privateKey, 'EdDSA'); +echo "Encode:\n" . print_r($jwt, true) . "\n"; + +$decoded = JWT::decode($jwt, $publicKey, array('EdDSA')); +echo "Decode:\n" . print_r((array) $decoded, true) . "\n"; +```` + Using JWKs ---------- diff --git a/composer.json b/composer.json index 25d1cfa9..6146e2dc 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,9 @@ "require": { "php": ">=5.3.0" }, + "suggest": { + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, "autoload": { "psr-4": { "Firebase\\JWT\\": "src" diff --git a/src/JWT.php b/src/JWT.php index 4b85699f..99d6dcd2 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -3,6 +3,7 @@ namespace Firebase\JWT; use DomainException; +use Exception; use InvalidArgumentException; use UnexpectedValueException; use DateTime; @@ -50,6 +51,7 @@ class JWT 'RS256' => array('openssl', 'SHA256'), 'RS384' => array('openssl', 'SHA384'), 'RS512' => array('openssl', 'SHA512'), + 'EdDSA' => array('sodium_crypto', 'EdDSA'), ); /** @@ -198,7 +200,7 @@ public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $he * * @return string An encrypted message * - * @throws DomainException Unsupported algorithm was specified + * @throws DomainException Unsupported algorithm or bad key was specified */ public static function sign($msg, $key, $alg = 'HS256') { @@ -214,14 +216,24 @@ public static function sign($msg, $key, $alg = 'HS256') $success = \openssl_sign($msg, $signature, $key, $algorithm); if (!$success) { throw new DomainException("OpenSSL unable to sign data"); - } else { - if ($alg === 'ES256') { - $signature = self::signatureFromDER($signature, 256); - } - if ($alg === 'ES384') { - $signature = self::signatureFromDER($signature, 384); - } - return $signature; + } + if ($alg === 'ES256') { + $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')) { + throw new DomainException('libsodium is not available'); + } + try { + // The last non-empty line is used as the key. + $lines = array_filter(explode("\n", $key)); + $key = base64_decode(end($lines)); + return sodium_crypto_sign_detached($msg, $key); + } catch (Exception $e) { + throw new DomainException($e->getMessage(), 0, $e); } } } @@ -237,7 +249,7 @@ public static function sign($msg, $key, $alg = 'HS256') * * @return bool * - * @throws DomainException Invalid Algorithm or OpenSSL failure + * @throws DomainException Invalid Algorithm, bad key, or OpenSSL failure */ private static function verify($msg, $signature, $key, $alg) { @@ -258,6 +270,18 @@ private static function verify($msg, $signature, $key, $alg) throw new DomainException( 'OpenSSL error: ' . \openssl_error_string() ); + case 'sodium_crypto': + if (!function_exists('sodium_crypto_sign_verify_detached')) { + throw new DomainException('libsodium is not available'); + } + try { + // The last non-empty line is used as the key. + $lines = array_filter(explode("\n", $key)); + $key = base64_decode(end($lines)); + return sodium_crypto_sign_verify_detached($signature, $msg, $key); + } catch (Exception $e) { + throw new DomainException($e->getMessage(), 0, $e); + } case 'hash_hmac': default: $hash = \hash_hmac($algorithm, $msg, $key, true); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 09dac142..3dee0450 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -285,6 +285,34 @@ public function testRSEncodeDecode() $this->assertEquals($decoded, 'abc'); } + public function testEdDsaEncodeDecode() + { + $keyPair = sodium_crypto_sign_keypair(); + $privKey = base64_encode(sodium_crypto_sign_secretkey($keyPair)); + + $payload = array('foo' => 'bar'); + $msg = JWT::encode($payload, $privKey, 'EdDSA'); + + $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); + $decoded = JWT::decode($msg, $pubKey, array('EdDSA')); + $this->assertEquals('bar', $decoded->foo); + } + + public function testInvalidEdDsaEncodeDecode() + { + $keyPair = sodium_crypto_sign_keypair(); + $privKey = base64_encode(sodium_crypto_sign_secretkey($keyPair)); + + $payload = array('foo' => 'bar'); + $msg = JWT::encode($payload, $privKey, 'EdDSA'); + + // Generate a different key. + $keyPair = sodium_crypto_sign_keypair(); + $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); + $this->setExpectedException('Firebase\JWT\SignatureInvalidException'); + JWT::decode($msg, $pubKey, array('EdDSA')); + } + public function testRSEncodeDecodeWithPassphrase() { $privateKey = openssl_pkey_get_private( @@ -322,6 +350,7 @@ public function provideEncodeDecode() array(__DIR__ . '/ecdsa-private.pem', __DIR__ . '/ecdsa-public.pem', 'ES256'), array(__DIR__ . '/ecdsa384-private.pem', __DIR__ . '/ecdsa384-public.pem', 'ES384'), array(__DIR__ . '/rsa1-private.pem', __DIR__ . '/rsa1-public.pub', 'RS512'), + array(__DIR__ . '/ed25519-1.sec', __DIR__ . '/ed25519-1.pub', 'EdDSA'), ); } } diff --git a/tests/ed25519-1.pub b/tests/ed25519-1.pub new file mode 100644 index 00000000..e4ae63ac --- /dev/null +++ b/tests/ed25519-1.pub @@ -0,0 +1 @@ +uOSJMhbKSG4V5xUHS7B9YHmVg/1yVd+G+Io6oBFhSfY= diff --git a/tests/ed25519-1.sec b/tests/ed25519-1.sec new file mode 100644 index 00000000..354ffa7a --- /dev/null +++ b/tests/ed25519-1.sec @@ -0,0 +1 @@ +i4eTKkWNIISKumdk3v90cPDrY/g8WRTJWy7DmGDsdzC45IkyFspIbhXnFQdLsH1geZWD/XJV34b4ijqgEWFJ9g== From 17513df3296103261e9b7de9582e6969d14149f3 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 3 Nov 2021 15:57:11 -0600 Subject: [PATCH 014/121] chore: fix tests (#366) --- .github/actions/entrypoint.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/entrypoint.sh b/.github/actions/entrypoint.sh index 8b6b9e1b..40402bc8 100755 --- a/.github/actions/entrypoint.sh +++ b/.github/actions/entrypoint.sh @@ -5,6 +5,7 @@ apt-get install -y --no-install-recommends \ git \ zip \ curl \ + ca-certificates \ unzip \ wget From 804585f8963a49cea3cea9f480e0051aa5593609 Mon Sep 17 00:00:00 2001 From: Sergiy Petrov Date: Thu, 4 Nov 2021 00:00:38 +0200 Subject: [PATCH 015/121] chore: add PHP 8.1 (#362) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 92b4e9e0..6f0f5e91 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0" ] + php: [ "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1"] name: PHP ${{matrix.php }} Unit Test steps: - uses: actions/checkout@v2 From bc0df6440dfe4099266a44e99a2839a1856b8ec0 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 4 Nov 2021 10:15:22 -0600 Subject: [PATCH 016/121] feat: add Key object to prevent key/algorithm type confusion (#365) --- README.md | 17 +++++-- src/JWT.php | 122 ++++++++++++++++++++++++++++++++++------------ src/Key.php | 59 ++++++++++++++++++++++ tests/JWTTest.php | 28 +++++++++++ 4 files changed, 190 insertions(+), 36 deletions(-) create mode 100644 src/Key.php diff --git a/README.md b/README.md index a8556aa5..ee98c47f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Example ------- ```php use Firebase\JWT\JWT; +use Firebase\JWT\Key; $key = "example_key"; $payload = array( @@ -43,7 +44,7 @@ $payload = array( * for a list of spec-compliant algorithms. */ $jwt = JWT::encode($payload, $key); -$decoded = JWT::decode($jwt, $key, array('HS256')); +$decoded = JWT::decode($jwt, new Key($key, 'HS256')); print_r($decoded); @@ -62,12 +63,13 @@ $decoded_array = (array) $decoded; * Source: http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html#nbfDef */ JWT::$leeway = 60; // $leeway in seconds -$decoded = JWT::decode($jwt, $key, array('HS256')); +$decoded = JWT::decode($jwt, new Key($key, 'HS256')); ``` Example with RS256 (openssl) ---------------------------- ```php use Firebase\JWT\JWT; +use Firebase\JWT\Key; $privateKey = << []]; // JWK::parseKeySet($jwks) returns an associative array of **kid** to private // key. Pass this as the second parameter to JWT::decode. +// NOTE: The deprecated $supportedAlgorithm must be supplied when parsing from JWK. JWT::decode($payload, JWK::parseKeySet($jwks), $supportedAlgorithm); ``` diff --git a/src/JWT.php b/src/JWT.php index 99d6dcd2..f46e8372 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -2,6 +2,7 @@ namespace Firebase\JWT; +use ArrayAccess; use DomainException; use Exception; use InvalidArgumentException; @@ -58,11 +59,13 @@ class JWT * Decodes a JWT string into a PHP object. * * @param string $jwt The JWT - * @param string|array|resource $key The key, or map of keys. + * @param Key|array $keyOrKeyArray The Key or array of Key objects. * If the algorithm used is asymmetric, this is the public key - * @param array $allowed_algs List of supported verification algorithms + * Each Key object contains an algorithm and matching key. * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', * 'HS512', 'RS256', 'RS384', and 'RS512' + * @param array $allowed_algs [DEPRECATED] List of supported verification algorithms. Only + * should be used for backwards compatibility. * * @return object The JWT's payload as a PHP object * @@ -76,11 +79,11 @@ class JWT * @uses jsonDecode * @uses urlsafeB64Decode */ - public static function decode($jwt, $key, array $allowed_algs = array()) + public static function decode($jwt, $keyOrKeyArray, array $allowed_algs = array()) { $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; - if (empty($key)) { + if (empty($keyOrKeyArray)) { throw new InvalidArgumentException('Key may not be empty'); } $tks = \explode('.', $jwt); @@ -103,27 +106,32 @@ public static function decode($jwt, $key, array $allowed_algs = array()) if (empty(static::$supported_algs[$header->alg])) { throw new UnexpectedValueException('Algorithm not supported'); } - if (!\in_array($header->alg, $allowed_algs)) { - throw new UnexpectedValueException('Algorithm not allowed'); + + list($keyMaterial, $algorithm) = self::getKeyMaterialAndAlgorithm( + $keyOrKeyArray, + empty($header->kid) ? null : $header->kid + ); + + if (empty($algorithm)) { + // Use deprecated "allowed_algs" to determine if the algorithm is supported. + // This opens up the possibility of an attack in some implementations. + // @see https://github.com/firebase/php-jwt/issues/351 + if (!\in_array($header->alg, $allowed_algs)) { + throw new UnexpectedValueException('Algorithm not allowed'); + } + } else { + // Check the algorithm + if (!self::constantTimeEquals($algorithm, $header->alg)) { + // 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 $sig = self::signatureToDER($sig); } - if (\is_array($key) || $key instanceof \ArrayAccess) { - if (isset($header->kid)) { - if (!isset($key[$header->kid])) { - throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); - } - $key = $key[$header->kid]; - } else { - throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); - } - } - - // Check the signature - if (!static::verify("$headb64.$bodyb64", $sig, $key, $header->alg)) { + if (!static::verify("$headb64.$bodyb64", $sig, $keyMaterial, $header->alg)) { throw new SignatureInvalidException('Signature verification failed'); } @@ -285,18 +293,7 @@ private static function verify($msg, $signature, $key, $alg) case 'hash_hmac': default: $hash = \hash_hmac($algorithm, $msg, $key, true); - if (\function_exists('hash_equals')) { - return \hash_equals($signature, $hash); - } - $len = \min(static::safeStrlen($signature), static::safeStrlen($hash)); - - $status = 0; - for ($i = 0; $i < $len; $i++) { - $status |= (\ord($signature[$i]) ^ \ord($hash[$i])); - } - $status |= (static::safeStrlen($signature) ^ static::safeStrlen($hash)); - - return ($status === 0); + return self::constantTimeEquals($signature, $hash); } } @@ -384,6 +381,69 @@ public static function urlsafeB64Encode($input) return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_')); } + + /** + * Determine if an algorithm has been provided for each Key + * + * @param string|array $keyOrKeyArray + * @param string|null $kid + * + * @return an array containing the keyMaterial and algorithm + */ + private static function getKeyMaterialAndAlgorithm($keyOrKeyArray, $kid = null) + { + if (is_string($keyOrKeyArray)) { + return array($keyOrKeyArray, null); + } + + if ($keyOrKeyArray instanceof Key) { + return array($keyOrKeyArray->getKeyMaterial(), $keyOrKeyArray->getAlgorithm()); + } + + if (is_array($keyOrKeyArray) || $keyOrKeyArray instanceof ArrayAccess) { + if (!isset($kid)) { + throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); + } + if (!isset($keyOrKeyArray[$kid])) { + throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); + } + + $key = $keyOrKeyArray[$kid]; + + if ($key instanceof Key) { + return array($key->getKeyMaterial(), $key->getAlgorithm()); + } + + return array($key, null); + } + + throw new UnexpectedValueException( + '$keyOrKeyArray must be a string key, an array of string keys, ' + . 'an instance of Firebase\JWT\Key key or an array of Firebase\JWT\Key keys' + ); + } + + /** + * @param string $left + * @param string $right + * @return bool + */ + public static function constantTimeEquals($left, $right) + { + if (\function_exists('hash_equals')) { + return \hash_equals($left, $right); + } + $len = \min(static::safeStrlen($left), static::safeStrlen($right)); + + $status = 0; + for ($i = 0; $i < $len; $i++) { + $status |= (\ord($left[$i]) ^ \ord($right[$i])); + } + $status |= (static::safeStrlen($left) ^ static::safeStrlen($right)); + + return ($status === 0); + } + /** * Helper method to create a JSON error. * diff --git a/src/Key.php b/src/Key.php new file mode 100644 index 00000000..f1ede6f2 --- /dev/null +++ b/src/Key.php @@ -0,0 +1,59 @@ +keyMaterial = $keyMaterial; + $this->algorithm = $algorithm; + } + + /** + * Return the algorithm valid for this key + * + * @return string + */ + public function getAlgorithm() + { + return $this->algorithm; + } + + /** + * @return string|resource|OpenSSLAsymmetricKey + */ + public function getKeyMaterial() + { + return $this->keyMaterial; + } +} diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 3dee0450..63386d88 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -344,6 +344,34 @@ public function testEncodeDecode($privateKeyFile, $publicKeyFile, $alg) $this->assertEquals('bar', $decoded->foo); } + /** + * @runInSeparateProcess + * @dataProvider provideEncodeDecode + */ + public function testEncodeDecodeWithKeyObject($privateKeyFile, $publicKeyFile, $alg) + { + $privateKey = file_get_contents($privateKeyFile); + $payload = array('foo' => 'bar'); + $encoded = JWT::encode($payload, $privateKey, $alg); + + // Verify decoding succeeds + $publicKey = file_get_contents($publicKeyFile); + $decoded = JWT::decode($encoded, new Key($publicKey, $alg)); + + $this->assertEquals('bar', $decoded->foo); + } + + public function testArrayAccessKIDChooserWithKeyObject() + { + $keys = new ArrayObject(array( + '1' => new Key('my_key', 'HS256'), + '2' => new Key('my_key2', 'HS256'), + )); + $msg = JWT::encode('abc', $keys['1']->getKeyMaterial(), 'HS256', '1'); + $decoded = JWT::decode($msg, $keys); + $this->assertEquals($decoded, 'abc'); + } + public function provideEncodeDecode() { return array( From cf814442ce0e9eebe5317d61b63ccda4b85de67a Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 4 Nov 2021 10:21:41 -0600 Subject: [PATCH 017/121] chore: explicit third parameter to decode function in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ee98c47f..1d392cd1 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ $payload = array( * https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40 * for a list of spec-compliant algorithms. */ -$jwt = JWT::encode($payload, $key); +$jwt = JWT::encode($payload, $key, 'HS256'); $decoded = JWT::decode($jwt, new Key($key, 'HS256')); print_r($decoded); From 83b609028194aa042ea33b5af2d41a7427de80e6 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 8 Nov 2021 14:18:51 -0600 Subject: [PATCH 018/121] fix: phpdoc and exception (#371) --- src/JWT.php | 17 ++++++++++++----- tests/JWTTest.php | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index f46e8372..ec1641bc 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -6,6 +6,7 @@ use DomainException; use Exception; use InvalidArgumentException; +use OpenSSLAsymmetricKey; use UnexpectedValueException; use DateTime; @@ -59,7 +60,7 @@ class JWT * Decodes a JWT string into a PHP object. * * @param string $jwt The JWT - * @param Key|array $keyOrKeyArray The Key or array of Key objects. + * @param Key|array|mixed $keyOrKeyArray The Key or array of 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', @@ -385,14 +386,20 @@ public static function urlsafeB64Encode($input) /** * Determine if an algorithm has been provided for each Key * - * @param string|array $keyOrKeyArray + * @param Key|array|mixed $keyOrKeyArray * @param string|null $kid * - * @return an array containing the keyMaterial and algorithm + * @throws UnexpectedValueException + * + * @return array containing the keyMaterial and algorithm */ private static function getKeyMaterialAndAlgorithm($keyOrKeyArray, $kid = null) { - if (is_string($keyOrKeyArray)) { + if ( + is_string($keyOrKeyArray) + || is_resource($keyOrKeyArray) + || $keyOrKeyArray instanceof OpenSSLAsymmetricKey + ) { return array($keyOrKeyArray, null); } @@ -418,7 +425,7 @@ private static function getKeyMaterialAndAlgorithm($keyOrKeyArray, $kid = null) } throw new UnexpectedValueException( - '$keyOrKeyArray must be a string key, an array of string keys, ' + '$keyOrKeyArray must be a string|resource key, an array of string|resource keys, ' . 'an instance of Firebase\JWT\Key key or an array of Firebase\JWT\Key keys' ); } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 63386d88..1c81c1ed 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -381,4 +381,19 @@ public function provideEncodeDecode() array(__DIR__ . '/ed25519-1.sec', __DIR__ . '/ed25519-1.pub', 'EdDSA'), ); } + + public function testEncodeDecodeWithResource() + { + $pem = file_get_contents(__DIR__ . '/rsa1-public.pub'); + $resource = openssl_pkey_get_public($pem); + $privateKey = file_get_contents(__DIR__ . '/rsa1-private.pem'); + + $payload = array('foo' => 'bar'); + $encoded = JWT::encode($payload, $privateKey, 'RS512'); + + // Verify decoding succeeds + $decoded = JWT::decode($encoded, $resource, array('RS512')); + + $this->assertEquals('bar', $decoded->foo); + } } From 12ec2fe16082a510b98e301cab07bbbcc5daea33 Mon Sep 17 00:00:00 2001 From: Andrej Rypo Date: Wed, 1 Dec 2021 23:04:08 +0100 Subject: [PATCH 019/121] chore(docs): add throws DomainException for JWT::decode (#379) --- src/JWT.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index ec1641bc..b2e78041 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -70,7 +70,8 @@ class JWT * * @return object The JWT's payload as a PHP object * - * @throws InvalidArgumentException Provided JWT was empty + * @throws InvalidArgumentException Provided key/key-array was empty + * @throws DomainException Provided JWT is malformed * @throws UnexpectedValueException Provided JWT was invalid * @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed * @throws BeforeValidException Provided JWT is trying to be used before it's eligible as defined by 'nbf' From 262f84c2dca214267051e8d311ee49bb96ec65c7 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 1 Dec 2021 17:04:45 -0500 Subject: [PATCH 020/121] chore: switch main to master (#383) --- .github/workflows/tests.yml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6f0f5e91..50d8a5f0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,7 @@ name: Test Suite on: push: branches: - - master + - main pull_request: jobs: diff --git a/README.md b/README.md index 1d392cd1..af7ed087 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://travis-ci.org/firebase/php-jwt.png?branch=master)](https://travis-ci.org/firebase/php-jwt) +[![Build Status](https://github.com/firebase/php-jwt/actions/workflows/tests.yml/badge.svg) [![Latest Stable Version](https://poser.pugx.org/firebase/php-jwt/v/stable)](https://packagist.org/packages/firebase/php-jwt) [![Total Downloads](https://poser.pugx.org/firebase/php-jwt/downloads)](https://packagist.org/packages/firebase/php-jwt) [![License](https://poser.pugx.org/firebase/php-jwt/license)](https://packagist.org/packages/firebase/php-jwt) From fbe639489d3348ff6e6d91f80b15198b39dc9217 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 1 Dec 2021 14:06:59 -0800 Subject: [PATCH 021/121] chore(docs): fix typo in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index af7ed087..acd1720c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://github.com/firebase/php-jwt/actions/workflows/tests.yml/badge.svg) +![Build Status](https://github.com/firebase/php-jwt/actions/workflows/tests.yml/badge.svg) [![Latest Stable Version](https://poser.pugx.org/firebase/php-jwt/v/stable)](https://packagist.org/packages/firebase/php-jwt) [![Total Downloads](https://poser.pugx.org/firebase/php-jwt/downloads)](https://packagist.org/packages/firebase/php-jwt) [![License](https://poser.pugx.org/firebase/php-jwt/license)](https://packagist.org/packages/firebase/php-jwt) From edda0f9ee45b8367699804f792a9be6d5175e816 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 30 Dec 2021 11:00:10 -0800 Subject: [PATCH 022/121] feat!: require Key object, use JSON_UNESCAPED_SLASHES, remove constants (#376) --- .gitignore | 1 + README.md | 3 +- src/JWK.php | 10 +- src/JWT.php | 94 ++++++------- tests/JWKTest.php | 38 +++-- tests/JWTTest.php | 168 +++++++++-------------- tests/autoload.php.dist | 17 --- tests/bootstrap.php | 13 +- tests/{ => data}/ecdsa-private.pem | 0 tests/{ => data}/ecdsa-public.pem | 0 tests/{ => data}/ecdsa384-private.pem | 0 tests/{ => data}/ecdsa384-public.pem | 0 tests/{ => data}/ed25519-1.pub | 0 tests/{ => data}/ed25519-1.sec | 0 tests/{ => data}/rsa-jwkset.json | 7 +- tests/{ => data}/rsa-with-passphrase.pem | 0 tests/{ => data}/rsa1-private.pem | 0 tests/{ => data}/rsa1-public.pub | 0 tests/{ => data}/rsa2-private.pem | 0 19 files changed, 161 insertions(+), 190 deletions(-) delete mode 100644 tests/autoload.php.dist rename tests/{ => data}/ecdsa-private.pem (100%) rename tests/{ => data}/ecdsa-public.pem (100%) rename tests/{ => data}/ecdsa384-private.pem (100%) rename tests/{ => data}/ecdsa384-public.pem (100%) rename tests/{ => data}/ed25519-1.pub (100%) rename tests/{ => data}/ed25519-1.sec (100%) rename tests/{ => data}/rsa-jwkset.json (86%) rename tests/{ => data}/rsa-with-passphrase.pem (100%) rename tests/{ => data}/rsa1-private.pem (100%) rename tests/{ => data}/rsa1-public.pub (100%) rename tests/{ => data}/rsa2-private.pem (100%) diff --git a/.gitignore b/.gitignore index 080f19aa..b22842cb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ phpunit.phar phpunit.phar.asc composer.phar composer.lock +.phpunit.result.cache diff --git a/README.md b/README.md index acd1720c..0a0023b2 100644 --- a/README.md +++ b/README.md @@ -200,8 +200,7 @@ $jwks = ['keys' => []]; // JWK::parseKeySet($jwks) returns an associative array of **kid** to private // key. Pass this as the second parameter to JWT::decode. -// NOTE: The deprecated $supportedAlgorithm must be supplied when parsing from JWK. -JWT::decode($payload, JWK::parseKeySet($jwks), $supportedAlgorithm); +JWT::decode($payload, JWK::parseKeySet($jwks)); ``` Changelog diff --git a/src/JWK.php b/src/JWK.php index 981a9ba7..c53251d3 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -47,7 +47,15 @@ public static function parseKeySet(array $jwks) foreach ($jwks['keys'] as $k => $v) { $kid = isset($v['kid']) ? $v['kid'] : $k; if ($key = self::parseKey($v)) { - $keys[$kid] = $key; + if (isset($v['alg'])) { + $keys[$kid] = new Key($key, $v['alg']); + } else { + // 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 InvalidArgumentException('JWK key is missing "alg"'); + } } } diff --git a/src/JWT.php b/src/JWT.php index b2e78041..e6038648 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -25,9 +25,12 @@ */ class JWT { - const ASN1_INTEGER = 0x02; - const ASN1_SEQUENCE = 0x10; - const ASN1_BIT_STRING = 0x03; + // const ASN1_INTEGER = 0x02; + // const ASN1_SEQUENCE = 0x10; + // const ASN1_BIT_STRING = 0x03; + private static $asn1Integer = 0x02; + private static $asn1Sequence = 0x10; + private static $asn1BitString = 0x03; /** * When checking nbf, iat or expiration times, @@ -60,13 +63,11 @@ class JWT * Decodes a JWT string into a PHP object. * * @param string $jwt The JWT - * @param Key|array|mixed $keyOrKeyArray The Key or array of Key objects. + * @param Key|array $keyOrKeyArray The Key or array of 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 array $allowed_algs [DEPRECATED] List of supported verification algorithms. Only - * should be used for backwards compatibility. * * @return object The JWT's payload as a PHP object * @@ -81,8 +82,9 @@ class JWT * @uses jsonDecode * @uses urlsafeB64Decode */ - public static function decode($jwt, $keyOrKeyArray, array $allowed_algs = array()) + public static function decode($jwt, $keyOrKeyArray) { + // Validate JWT $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; if (empty($keyOrKeyArray)) { @@ -109,31 +111,18 @@ public static function decode($jwt, $keyOrKeyArray, array $allowed_algs = array( throw new UnexpectedValueException('Algorithm not supported'); } - list($keyMaterial, $algorithm) = self::getKeyMaterialAndAlgorithm( - $keyOrKeyArray, - empty($header->kid) ? null : $header->kid - ); + $key = self::getKey($keyOrKeyArray, empty($header->kid) ? null : $header->kid); - if (empty($algorithm)) { - // Use deprecated "allowed_algs" to determine if the algorithm is supported. - // This opens up the possibility of an attack in some implementations. - // @see https://github.com/firebase/php-jwt/issues/351 - if (!\in_array($header->alg, $allowed_algs)) { - throw new UnexpectedValueException('Algorithm not allowed'); - } - } else { - // Check the algorithm - if (!self::constantTimeEquals($algorithm, $header->alg)) { - // See issue #351 - throw new UnexpectedValueException('Incorrect key for this algorithm'); - } + // Check the algorithm + if (!self::constantTimeEquals($key->getAlgorithm(), $header->alg)) { + // 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 $sig = self::signatureToDER($sig); } - - if (!static::verify("$headb64.$bodyb64", $sig, $keyMaterial, $header->alg)) { + if (!static::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) { throw new SignatureInvalidException('Signature verification failed'); } @@ -179,7 +168,7 @@ public static function decode($jwt, $keyOrKeyArray, array $allowed_algs = array( * @uses jsonEncode * @uses urlsafeB64Encode */ - public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $head = null) + public static function encode($payload, $key, $alg, $keyId = null, $head = null) { $header = array('typ' => 'JWT', 'alg' => $alg); if ($keyId !== null) { @@ -212,7 +201,7 @@ public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $he * * @throws DomainException Unsupported algorithm or bad key was specified */ - public static function sign($msg, $key, $alg = 'HS256') + public static function sign($msg, $key, $alg) { if (empty(static::$supported_algs[$alg])) { throw new DomainException('Algorithm not supported'); @@ -345,7 +334,12 @@ public static function jsonDecode($input) */ public static function jsonEncode($input) { - $json = \json_encode($input); + if (PHP_VERSION_ID >= 50400) { + $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); + } else { + // PHP 5.3 only + $json = \json_encode($input); + } if ($errno = \json_last_error()) { static::handleJsonError($errno); } elseif ($json === 'null' && $input !== null) { @@ -394,21 +388,21 @@ public static function urlsafeB64Encode($input) * * @return array containing the keyMaterial and algorithm */ - private static function getKeyMaterialAndAlgorithm($keyOrKeyArray, $kid = null) + private static function getKey($keyOrKeyArray, $kid = null) { - if ( - is_string($keyOrKeyArray) - || is_resource($keyOrKeyArray) - || $keyOrKeyArray instanceof OpenSSLAsymmetricKey - ) { - return array($keyOrKeyArray, null); - } - if ($keyOrKeyArray instanceof Key) { - return array($keyOrKeyArray->getKeyMaterial(), $keyOrKeyArray->getAlgorithm()); + return $keyOrKeyArray; } if (is_array($keyOrKeyArray) || $keyOrKeyArray instanceof ArrayAccess) { + foreach ($keyOrKeyArray as $keyId => $key) { + if (!$key instanceof Key) { + throw new UnexpectedValueException( + '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an ' + . 'array of Firebase\JWT\Key keys' + ); + } + } if (!isset($kid)) { throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); } @@ -416,18 +410,12 @@ private static function getKeyMaterialAndAlgorithm($keyOrKeyArray, $kid = null) throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); } - $key = $keyOrKeyArray[$kid]; - - if ($key instanceof Key) { - return array($key->getKeyMaterial(), $key->getAlgorithm()); - } - - return array($key, null); + return $keyOrKeyArray[$kid]; } throw new UnexpectedValueException( - '$keyOrKeyArray must be a string|resource key, an array of string|resource keys, ' - . 'an instance of Firebase\JWT\Key key or an array of Firebase\JWT\Key keys' + '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an ' + . 'array of Firebase\JWT\Key keys' ); } @@ -515,9 +503,9 @@ private static function signatureToDER($sig) } return self::encodeDER( - self::ASN1_SEQUENCE, - self::encodeDER(self::ASN1_INTEGER, $r) . - self::encodeDER(self::ASN1_INTEGER, $s) + self::$asn1Sequence, + self::encodeDER(self::$asn1Integer, $r) . + self::encodeDER(self::$asn1Integer, $s) ); } @@ -531,7 +519,7 @@ private static function signatureToDER($sig) private static function encodeDER($type, $value) { $tag_header = 0; - if ($type === self::ASN1_SEQUENCE) { + if ($type === self::$asn1Sequence) { $tag_header |= 0x20; } @@ -596,7 +584,7 @@ private static function readDER($der, $offset = 0) } // Value - if ($type == self::ASN1_BIT_STRING) { + if ($type == self::$asn1BitString) { $pos++; // Skip the first contents octet (padding indicator) $data = \substr($der, $pos, $len - 1); $pos += $len - 1; diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 0709836d..b908ea64 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -38,26 +38,42 @@ public function testParsePrivateKey() 'UnexpectedValueException', 'RSA private keys are not supported' ); - + $jwkSet = json_decode( - file_get_contents(__DIR__ . '/rsa-jwkset.json'), + file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), true ); $jwkSet['keys'][0]['d'] = 'privatekeyvalue'; - + + JWK::parseKeySet($jwkSet); + } + + public function testParsePrivateKeyWithoutAlg() + { + $this->setExpectedException( + 'InvalidArgumentException', + 'JWK key is missing "alg"' + ); + + $jwkSet = json_decode( + file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), + true + ); + unset($jwkSet['keys'][0]['alg']); + JWK::parseKeySet($jwkSet); } - + public function testParseKeyWithEmptyDValue() { $jwkSet = json_decode( - file_get_contents(__DIR__ . '/rsa-jwkset.json'), + file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), true ); - + // empty or null values are ok $jwkSet['keys'][0]['d'] = null; - + $keys = JWK::parseKeySet($jwkSet); $this->assertTrue(is_array($keys)); } @@ -65,7 +81,7 @@ public function testParseKeyWithEmptyDValue() public function testParseJwkKeySet() { $jwkSet = json_decode( - file_get_contents(__DIR__ . '/rsa-jwkset.json'), + file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), true ); $keys = JWK::parseKeySet($jwkSet); @@ -93,7 +109,7 @@ public function testParseJwkKeySet_empty() */ public function testDecodeByJwkKeySetTokenExpired() { - $privKey1 = file_get_contents(__DIR__ . '/rsa1-private.pem'); + $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); $payload = array('exp' => strtotime('-1 hour')); $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); @@ -107,7 +123,7 @@ public function testDecodeByJwkKeySetTokenExpired() */ public function testDecodeByJwkKeySet() { - $privKey1 = file_get_contents(__DIR__ . '/rsa1-private.pem'); + $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); $payload = array('sub' => 'foo', 'exp' => strtotime('+10 seconds')); $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); @@ -121,7 +137,7 @@ public function testDecodeByJwkKeySet() */ public function testDecodeByMultiJwkKeySet() { - $privKey2 = file_get_contents(__DIR__ . '/rsa2-private.pem'); + $privKey2 = file_get_contents(__DIR__ . '/data/rsa2-private.pem'); $payload = array('sub' => 'bar', 'exp' => strtotime('+10 seconds')); $msg = JWT::encode($payload, $privKey2, 'RS256', 'jwk2'); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 1c81c1ed..36e2095e 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -23,21 +23,21 @@ public function testDecodeFromPython() { $msg = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.Iio6aHR0cDovL2FwcGxpY2F0aW9uL2NsaWNreT9ibGFoPTEuMjMmZi5vbz00NTYgQUMwMDAgMTIzIg.E_U8X2YpMT5K1cEiT_3-IvBYfrdIFIeVYeOqre_Z5Cg'; $this->assertEquals( - JWT::decode($msg, 'my_key', array('HS256')), + JWT::decode($msg, new Key('my_key', 'HS256')), '*:http://application/clicky?blah=1.23&f.oo=456 AC000 123' ); } public function testUrlSafeCharacters() { - $encoded = JWT::encode('f?', 'a'); - $this->assertEquals('f?', JWT::decode($encoded, 'a', array('HS256'))); + $encoded = JWT::encode('f?', 'a', 'HS256'); + $this->assertEquals('f?', JWT::decode($encoded, new Key('a', 'HS256'))); } public function testMalformedUtf8StringsFail() { $this->setExpectedException('DomainException'); - JWT::encode(pack('c', 128), 'a'); + JWT::encode(pack('c', 128), 'a', 'HS256'); } public function testMalformedJsonThrowsException() @@ -52,8 +52,8 @@ public function testExpiredToken() $payload = array( "message" => "abc", "exp" => time() - 20); // time in the past - $encoded = JWT::encode($payload, 'my_key'); - JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + JWT::decode($encoded, new Key('my_key', 'HS256')); } public function testBeforeValidTokenWithNbf() @@ -62,8 +62,8 @@ public function testBeforeValidTokenWithNbf() $payload = array( "message" => "abc", "nbf" => time() + 20); // time in the future - $encoded = JWT::encode($payload, 'my_key'); - JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + JWT::decode($encoded, new Key('my_key', 'HS256')); } public function testBeforeValidTokenWithIat() @@ -72,8 +72,8 @@ public function testBeforeValidTokenWithIat() $payload = array( "message" => "abc", "iat" => time() + 20); // time in the future - $encoded = JWT::encode($payload, 'my_key'); - JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + JWT::decode($encoded, new Key('my_key', 'HS256')); } public function testValidToken() @@ -81,8 +81,8 @@ public function testValidToken() $payload = array( "message" => "abc", "exp" => time() + JWT::$leeway + 20); // time in the future - $encoded = JWT::encode($payload, 'my_key'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); } @@ -92,8 +92,8 @@ public function testValidTokenWithLeeway() $payload = array( "message" => "abc", "exp" => time() - 20); // time in the past - $encoded = JWT::encode($payload, 'my_key'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); JWT::$leeway = 0; } @@ -105,22 +105,12 @@ public function testExpiredTokenWithLeeway() "message" => "abc", "exp" => time() - 70); // time far in the past $this->setExpectedException('Firebase\JWT\ExpiredException'); - $encoded = JWT::encode($payload, 'my_key'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); JWT::$leeway = 0; } - public function testValidTokenWithList() - { - $payload = array( - "message" => "abc", - "exp" => time() + 20); // time in the future - $encoded = JWT::encode($payload, 'my_key'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256', 'HS512')); - $this->assertEquals($decoded->message, 'abc'); - } - public function testValidTokenWithNbf() { $payload = array( @@ -128,8 +118,8 @@ public function testValidTokenWithNbf() "iat" => time(), "exp" => time() + 20, // time in the future "nbf" => time() - 20); - $encoded = JWT::encode($payload, 'my_key'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); } @@ -139,8 +129,8 @@ public function testValidTokenWithNbfLeeway() $payload = array( "message" => "abc", "nbf" => time() + 20); // not before in near (leeway) future - $encoded = JWT::encode($payload, 'my_key'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); JWT::$leeway = 0; } @@ -151,9 +141,9 @@ public function testInvalidTokenWithNbfLeeway() $payload = array( "message" => "abc", "nbf" => time() + 65); // not before too far in future - $encoded = JWT::encode($payload, 'my_key'); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->setExpectedException('Firebase\JWT\BeforeValidException'); - JWT::decode($encoded, 'my_key', array('HS256')); + JWT::decode($encoded, new Key('my_key', 'HS256')); JWT::$leeway = 0; } @@ -163,8 +153,8 @@ public function testValidTokenWithIatLeeway() $payload = array( "message" => "abc", "iat" => time() + 20); // issued in near (leeway) future - $encoded = JWT::encode($payload, 'my_key'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); JWT::$leeway = 0; } @@ -175,9 +165,9 @@ public function testInvalidTokenWithIatLeeway() $payload = array( "message" => "abc", "iat" => time() + 65); // issued too far in future - $encoded = JWT::encode($payload, 'my_key'); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->setExpectedException('Firebase\JWT\BeforeValidException'); - JWT::decode($encoded, 'my_key', array('HS256')); + JWT::decode($encoded, new Key('my_key', 'HS256')); JWT::$leeway = 0; } @@ -186,9 +176,9 @@ public function testInvalidToken() $payload = array( "message" => "abc", "exp" => time() + 20); // time in the future - $encoded = JWT::encode($payload, 'my_key'); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->setExpectedException('Firebase\JWT\SignatureInvalidException'); - JWT::decode($encoded, 'my_key2', array('HS256')); + JWT::decode($encoded, new Key('my_key2', 'HS256')); } public function testNullKeyFails() @@ -196,9 +186,9 @@ public function testNullKeyFails() $payload = array( "message" => "abc", "exp" => time() + JWT::$leeway + 20); // time in the future - $encoded = JWT::encode($payload, 'my_key'); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->setExpectedException('InvalidArgumentException'); - JWT::decode($encoded, null, array('HS256')); + JWT::decode($encoded, new Key(null, 'HS256')); } public function testEmptyKeyFails() @@ -206,71 +196,77 @@ public function testEmptyKeyFails() $payload = array( "message" => "abc", "exp" => time() + JWT::$leeway + 20); // time in the future - $encoded = JWT::encode($payload, 'my_key'); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->setExpectedException('InvalidArgumentException'); - JWT::decode($encoded, '', array('HS256')); + JWT::decode($encoded, new Key('', 'HS256')); } public function testKIDChooser() { - $keys = array('1' => 'my_key', '2' => 'my_key2'); - $msg = JWT::encode('abc', $keys['1'], 'HS256', '1'); - $decoded = JWT::decode($msg, $keys, array('HS256')); + $keys = array( + '1' => new Key('my_key', 'HS256'), + '2' => new Key('my_key2', 'HS256') + ); + $msg = JWT::encode('abc', $keys['1']->getKeyMaterial(), 'HS256', '1'); + $decoded = JWT::decode($msg, $keys); $this->assertEquals($decoded, 'abc'); } public function testArrayAccessKIDChooser() { - $keys = new ArrayObject(array('1' => 'my_key', '2' => 'my_key2')); - $msg = JWT::encode('abc', $keys['1'], 'HS256', '1'); - $decoded = JWT::decode($msg, $keys, array('HS256')); + $keys = new ArrayObject(array( + '1' => new Key('my_key', 'HS256'), + '2' => new Key('my_key2', 'HS256'), + )); + $msg = JWT::encode('abc', $keys['1']->getKeyMaterial(), 'HS256', '1'); + $decoded = JWT::decode($msg, $keys); $this->assertEquals($decoded, 'abc'); } public function testNoneAlgorithm() { - $msg = JWT::encode('abc', 'my_key'); + $msg = JWT::encode('abc', 'my_key', 'HS256'); $this->setExpectedException('UnexpectedValueException'); - JWT::decode($msg, 'my_key', array('none')); + JWT::decode($msg, new Key('my_key', 'none')); } public function testIncorrectAlgorithm() { - $msg = JWT::encode('abc', 'my_key'); + $msg = JWT::encode('abc', 'my_key', 'HS256'); $this->setExpectedException('UnexpectedValueException'); - JWT::decode($msg, 'my_key', array('RS256')); + JWT::decode($msg, new Key('my_key', 'RS256')); } - public function testMissingAlgorithm() + public function testEmptyAlgorithm() { - $msg = JWT::encode('abc', 'my_key'); + $msg = JWT::encode('abc', 'my_key', 'HS256'); $this->setExpectedException('UnexpectedValueException'); - JWT::decode($msg, 'my_key'); + JWT::decode($msg, new Key('my_key', '')); } public function testAdditionalHeaders() { $msg = JWT::encode('abc', 'my_key', 'HS256', null, array('cty' => 'test-eit;v=1')); - $this->assertEquals(JWT::decode($msg, 'my_key', array('HS256')), 'abc'); + $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), 'abc'); } public function testInvalidSegmentCount() { $this->setExpectedException('UnexpectedValueException'); - JWT::decode('brokenheader.brokenbody', 'my_key', array('HS256')); + JWT::decode('brokenheader.brokenbody', new Key('my_key', 'HS256')); } public function testInvalidSignatureEncoding() { $msg = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6ImZvbyJ9.Q4Kee9E8o0Xfo4ADXvYA8t7dN_X_bU9K5w6tXuiSjlUxx"; $this->setExpectedException('UnexpectedValueException'); - JWT::decode($msg, 'secret', array('HS256')); + JWT::decode($msg, new Key('secret', 'HS256')); } public function testHSEncodeDecode() { - $msg = JWT::encode('abc', 'my_key'); - $this->assertEquals(JWT::decode($msg, 'my_key', array('HS256')), 'abc'); + $msg = JWT::encode('abc', 'my_key', 'HS256'); + $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), 'abc'); } public function testRSEncodeDecode() @@ -281,7 +277,7 @@ public function testRSEncodeDecode() $msg = JWT::encode('abc', $privKey, 'RS256'); $pubKey = openssl_pkey_get_details($privKey); $pubKey = $pubKey['key']; - $decoded = JWT::decode($msg, $pubKey, array('RS256')); + $decoded = JWT::decode($msg, new Key($pubKey, 'RS256')); $this->assertEquals($decoded, 'abc'); } @@ -294,7 +290,7 @@ public function testEdDsaEncodeDecode() $msg = JWT::encode($payload, $privKey, 'EdDSA'); $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); - $decoded = JWT::decode($msg, $pubKey, array('EdDSA')); + $decoded = JWT::decode($msg, new Key($pubKey, 'EdDSA')); $this->assertEquals('bar', $decoded->foo); } @@ -310,20 +306,20 @@ public function testInvalidEdDsaEncodeDecode() $keyPair = sodium_crypto_sign_keypair(); $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); $this->setExpectedException('Firebase\JWT\SignatureInvalidException'); - JWT::decode($msg, $pubKey, array('EdDSA')); + JWT::decode($msg, new Key($pubKey, 'EdDSA')); } public function testRSEncodeDecodeWithPassphrase() { $privateKey = openssl_pkey_get_private( - file_get_contents(__DIR__ . '/rsa-with-passphrase.pem'), + file_get_contents(__DIR__ . '/data/rsa-with-passphrase.pem'), 'passphrase' ); $jwt = JWT::encode('abc', $privateKey, 'RS256'); $keyDetails = openssl_pkey_get_details($privateKey); $pubKey = $keyDetails['key']; - $decoded = JWT::decode($jwt, $pubKey, array('RS256')); + $decoded = JWT::decode($jwt, new Key($pubKey, 'RS256')); $this->assertEquals($decoded, 'abc'); } @@ -337,23 +333,6 @@ public function testEncodeDecode($privateKeyFile, $publicKeyFile, $alg) $payload = array('foo' => 'bar'); $encoded = JWT::encode($payload, $privateKey, $alg); - // Verify decoding succeeds - $publicKey = file_get_contents($publicKeyFile); - $decoded = JWT::decode($encoded, $publicKey, array($alg)); - - $this->assertEquals('bar', $decoded->foo); - } - - /** - * @runInSeparateProcess - * @dataProvider provideEncodeDecode - */ - public function testEncodeDecodeWithKeyObject($privateKeyFile, $publicKeyFile, $alg) - { - $privateKey = file_get_contents($privateKeyFile); - $payload = array('foo' => 'bar'); - $encoded = JWT::encode($payload, $privateKey, $alg); - // Verify decoding succeeds $publicKey = file_get_contents($publicKeyFile); $decoded = JWT::decode($encoded, new Key($publicKey, $alg)); @@ -361,38 +340,27 @@ public function testEncodeDecodeWithKeyObject($privateKeyFile, $publicKeyFile, $ $this->assertEquals('bar', $decoded->foo); } - public function testArrayAccessKIDChooserWithKeyObject() - { - $keys = new ArrayObject(array( - '1' => new Key('my_key', 'HS256'), - '2' => new Key('my_key2', 'HS256'), - )); - $msg = JWT::encode('abc', $keys['1']->getKeyMaterial(), 'HS256', '1'); - $decoded = JWT::decode($msg, $keys); - $this->assertEquals($decoded, 'abc'); - } - public function provideEncodeDecode() { return array( - array(__DIR__ . '/ecdsa-private.pem', __DIR__ . '/ecdsa-public.pem', 'ES256'), - array(__DIR__ . '/ecdsa384-private.pem', __DIR__ . '/ecdsa384-public.pem', 'ES384'), - array(__DIR__ . '/rsa1-private.pem', __DIR__ . '/rsa1-public.pub', 'RS512'), - array(__DIR__ . '/ed25519-1.sec', __DIR__ . '/ed25519-1.pub', 'EdDSA'), + array(__DIR__ . '/data/ecdsa-private.pem', __DIR__ . '/data/ecdsa-public.pem', 'ES256'), + array(__DIR__ . '/data/ecdsa384-private.pem', __DIR__ . '/data/ecdsa384-public.pem', 'ES384'), + array(__DIR__ . '/data/rsa1-private.pem', __DIR__ . '/data/rsa1-public.pub', 'RS512'), + array(__DIR__ . '/data/ed25519-1.sec', __DIR__ . '/data/ed25519-1.pub', 'EdDSA'), ); } public function testEncodeDecodeWithResource() { - $pem = file_get_contents(__DIR__ . '/rsa1-public.pub'); + $pem = file_get_contents(__DIR__ . '/data/rsa1-public.pub'); $resource = openssl_pkey_get_public($pem); - $privateKey = file_get_contents(__DIR__ . '/rsa1-private.pem'); + $privateKey = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); $payload = array('foo' => 'bar'); $encoded = JWT::encode($payload, $privateKey, 'RS512'); // Verify decoding succeeds - $decoded = JWT::decode($encoded, $resource, array('RS512')); + $decoded = JWT::decode($encoded, new Key($resource, 'RS512')); $this->assertEquals('bar', $decoded->foo); } diff --git a/tests/autoload.php.dist b/tests/autoload.php.dist deleted file mode 100644 index 2e4310a0..00000000 --- a/tests/autoload.php.dist +++ /dev/null @@ -1,17 +0,0 @@ - Date: Mon, 24 Jan 2022 06:32:49 -0800 Subject: [PATCH 023/121] chore: update changelog for v6.0.0 (#391) --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 0a0023b2..26e0436b 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,14 @@ JWT::decode($payload, JWK::parseKeySet($jwks)); Changelog --------- +#### 6.0.0 / 2022-01-24 + + - 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)! From 0541cba75ab108ef901985e68055a92646c73534 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 24 Jan 2022 07:18:34 -0800 Subject: [PATCH 024/121] feat!: update return type for JWK methods (#392) --- README.md | 5 +++-- src/JWK.php | 22 ++++++++++------------ src/JWT.php | 6 +++--- tests/JWKTest.php | 6 +++--- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 26e0436b..7839af60 100644 --- a/README.md +++ b/README.md @@ -198,8 +198,8 @@ use Firebase\JWT\JWT; // this endpoint: https://www.gstatic.com/iap/verify/public_key-jwk $jwks = ['keys' => []]; -// JWK::parseKeySet($jwks) returns an associative array of **kid** to private -// key. Pass this as the second parameter to JWT::decode. +// 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)); ``` @@ -208,6 +208,7 @@ Changelog #### 6.0.0 / 2022-01-24 + - **Backwards-Compatibility Breaking Changes**: See the [Release Notes](https://github.com/firebase/php-jwt/releases/tag/v5.5.1) for more information. - New Key object to prevent key/algorithm type confusion (#365) - Add JWK support (#273) - Add ES256 support (#256) diff --git a/src/JWK.php b/src/JWK.php index c53251d3..c5506548 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -25,7 +25,7 @@ class JWK * * @param array $jwks The JSON Web Key Set as an associative array * - * @return array An associative array that represents the set of keys + * @return array An associative array of key IDs (kid) to Key objects * * @throws InvalidArgumentException Provided JWK Set is empty * @throws UnexpectedValueException Provided JWK Set was invalid @@ -47,15 +47,7 @@ public static function parseKeySet(array $jwks) foreach ($jwks['keys'] as $k => $v) { $kid = isset($v['kid']) ? $v['kid'] : $k; if ($key = self::parseKey($v)) { - if (isset($v['alg'])) { - $keys[$kid] = new Key($key, $v['alg']); - } else { - // 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 InvalidArgumentException('JWK key is missing "alg"'); - } + $keys[$kid] = $key; } } @@ -71,7 +63,7 @@ public static function parseKeySet(array $jwks) * * @param array $jwk An individual JWK * - * @return resource|array An associative array that represents the key + * @return Key The key object for the JWK * * @throws InvalidArgumentException Provided JWK is empty * @throws UnexpectedValueException Provided JWK was invalid @@ -87,6 +79,12 @@ public static function parseKey(array $jwk) if (!isset($jwk['kty'])) { throw new UnexpectedValueException('JWK must contain a "kty" parameter'); } + 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'); + } switch ($jwk['kty']) { case 'RSA': @@ -104,7 +102,7 @@ public static function parseKey(array $jwk) 'OpenSSL error: ' . \openssl_error_string() ); } - return $publicKey; + return new Key($publicKey, $jwk['alg']); default: // Currently only RSA is supported break; diff --git a/src/JWT.php b/src/JWT.php index e6038648..725a0832 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -63,7 +63,7 @@ class JWT * Decodes a JWT string into a PHP object. * * @param string $jwt The JWT - * @param Key|array $keyOrKeyArray The Key or array of Key objects. + * @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', @@ -381,8 +381,8 @@ public static function urlsafeB64Encode($input) /** * Determine if an algorithm has been provided for each Key * - * @param Key|array|mixed $keyOrKeyArray - * @param string|null $kid + * @param Key|array $keyOrKeyArray + * @param string|null $kid * * @throws UnexpectedValueException * diff --git a/tests/JWKTest.php b/tests/JWKTest.php index b908ea64..c580f40f 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -28,7 +28,7 @@ public function testInvalidAlgorithm() 'No supported algorithms found in JWK Set' ); - $badJwk = array('kty' => 'BADALG'); + $badJwk = array('kty' => 'BADALG', 'alg' => 'RSA256'); $keys = JWK::parseKeySet(array('keys' => array($badJwk))); } @@ -51,8 +51,8 @@ public function testParsePrivateKey() public function testParsePrivateKeyWithoutAlg() { $this->setExpectedException( - 'InvalidArgumentException', - 'JWK key is missing "alg"' + 'UnexpectedValueException', + 'JWK must contain an "alg" parameter' ); $jwkSet = json_decode( From abc63f3fc6c723543a0d8b0b207d62fc34f83510 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 7 Feb 2022 06:54:21 -0800 Subject: [PATCH 025/121] fix: correct order for hash_equals (#393) --- src/JWT.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 725a0832..1226102c 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -284,7 +284,7 @@ private static function verify($msg, $signature, $key, $alg) case 'hash_hmac': default: $hash = \hash_hmac($algorithm, $msg, $key, true); - return self::constantTimeEquals($signature, $hash); + return self::constantTimeEquals($hash, $signature); } } @@ -420,8 +420,8 @@ private static function getKey($keyOrKeyArray, $kid = null) } /** - * @param string $left - * @param string $right + * @param string $left The string of known length to compare against + * @param string $right The user-supplied string * @return bool */ public static function constantTimeEquals($left, $right) From 7e0a273c602f30cc947c3828e84d4ecc23202a0b Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 7 Feb 2022 07:04:10 -0800 Subject: [PATCH 026/121] fix: use property_exists for checking key id (#395) --- src/JWT.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index 1226102c..6130c59c 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -111,7 +111,7 @@ public static function decode($jwt, $keyOrKeyArray) throw new UnexpectedValueException('Algorithm not supported'); } - $key = self::getKey($keyOrKeyArray, empty($header->kid) ? null : $header->kid); + $key = self::getKey($keyOrKeyArray, property_exists($header, 'kid') ? $header->kid : null); // Check the algorithm if (!self::constantTimeEquals($key->getAlgorithm(), $header->alg)) { From 8bcbcf8f652813bb3d15839b29fa75c665a9f68f Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 7 Feb 2022 17:45:09 +0200 Subject: [PATCH 027/121] chore(docs): fix release URL in README (#394) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7839af60..2300ae30 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ Changelog #### 6.0.0 / 2022-01-24 - - **Backwards-Compatibility Breaking Changes**: See the [Release Notes](https://github.com/firebase/php-jwt/releases/tag/v5.5.1) for more information. + - **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) From f8550f8b2848da8bf41871b8051b9c7aabb0bd91 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 16 Feb 2022 06:14:33 -0800 Subject: [PATCH 028/121] feat: add typing, require PHP 8.0 (#385) --- .github/workflows/tests.yml | 40 +------- composer.json | 4 +- src/JWK.php | 15 ++- src/JWT.php | 197 +++++++++++++++++------------------- src/Key.php | 35 +++---- tests/JWKTest.php | 40 ++++---- tests/JWTTest.php | 185 +++++++++++++++++---------------- 7 files changed, 239 insertions(+), 277 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 50d8a5f0..873eae24 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1"] + php: [ "8.0", "8.1"] name: PHP ${{matrix.php }} Unit Test steps: - uses: actions/checkout@v2 @@ -24,45 +24,9 @@ jobs: timeout_minutes: 10 max_attempts: 3 command: composer install - - if: ${{ matrix.php == '5.6' }} - run: composer require --dev --with-dependencies paragonie/sodium_compat - name: Run Script run: vendor/bin/phpunit - # use dockerfiles for old versions of php (setup-php times out for those). - test_php55: - name: "PHP 5.5 Unit Test" - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Run Unit Tests - uses: docker://php:5.5-cli - with: - entrypoint: ./.github/actions/entrypoint.sh - - test_php54: - name: "PHP 5.4 Unit Test" - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Run Unit Tests - uses: docker://php:5.4-cli - with: - entrypoint: ./.github/actions/entrypoint.sh - - test_php53: - name: "PHP 5.3 Unit Test" - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Run Unit Tests - uses: docker://tomsowerby/php-5.3:cli - with: - entrypoint: ./.github/actions/entrypoint.sh - style: runs-on: ubuntu-latest name: PHP Style Check @@ -71,7 +35,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: "7.0" + php-version: "8.0" - name: Run Script run: | composer require friendsofphp/php-cs-fixer diff --git a/composer.json b/composer.json index 6146e2dc..4e190ea3 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ ], "license": "BSD-3-Clause", "require": { - "php": ">=5.3.0" + "php": "^8.0" }, "suggest": { "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" @@ -31,6 +31,6 @@ } }, "require-dev": { - "phpunit/phpunit": ">=4.8 <=9" + "phpunit/phpunit": "^9.5" } } diff --git a/src/JWK.php b/src/JWK.php index c5506548..5663c948 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -33,13 +33,14 @@ class JWK * * @uses parseKey */ - public static function parseKeySet(array $jwks) + public static function parseKeySet(array $jwks): array { - $keys = array(); + $keys = []; 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'); } @@ -71,14 +72,16 @@ public static function parseKeySet(array $jwks) * * @uses createPemFromModulusAndExponent */ - public static function parseKey(array $jwk) + public static function parseKey(array $jwk): ?Key { if (empty($jwk)) { throw new InvalidArgumentException('JWK must not be empty'); } + if (!isset($jwk['kty'])) { throw new UnexpectedValueException('JWK must contain a "kty" parameter'); } + 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. @@ -107,6 +110,8 @@ public static function parseKey(array $jwk) // Currently only RSA is supported break; } + + return null; } /** @@ -124,10 +129,10 @@ private static function createPemFromModulusAndExponent($n, $e) $modulus = JWT::urlsafeB64Decode($n); $publicExponent = JWT::urlsafeB64Decode($e); - $components = array( + $components = [ 'modulus' => \pack('Ca*a*', 2, self::encodeLength(\strlen($modulus)), $modulus), 'publicExponent' => \pack('Ca*a*', 2, self::encodeLength(\strlen($publicExponent)), $publicExponent) - ); + ]; $rsaPublicKey = \pack( 'Ca*a*a*', diff --git a/src/JWT.php b/src/JWT.php index 6130c59c..f5852dcd 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -9,6 +9,7 @@ use OpenSSLAsymmetricKey; use UnexpectedValueException; use DateTime; +use stdClass; /** * JSON Web Token implementation, based on this spec: @@ -25,51 +26,40 @@ */ class JWT { - // const ASN1_INTEGER = 0x02; - // const ASN1_SEQUENCE = 0x10; - // const ASN1_BIT_STRING = 0x03; - private static $asn1Integer = 0x02; - private static $asn1Sequence = 0x10; - private static $asn1BitString = 0x03; + private const ASN1_INTEGER = 0x02; + private const ASN1_SEQUENCE = 0x10; + private const ASN1_BIT_STRING = 0x03; /** * When checking nbf, iat or expiration times, * we want to provide some extra leeway time to * account for clock skew. */ - 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. - */ - public static $timestamp = null; - - public static $supported_algs = array( - 'ES384' => array('openssl', 'SHA384'), - 'ES256' => array('openssl', 'SHA256'), - 'HS256' => array('hash_hmac', 'SHA256'), - 'HS384' => array('hash_hmac', 'SHA384'), - 'HS512' => array('hash_hmac', 'SHA512'), - 'RS256' => array('openssl', 'SHA256'), - 'RS384' => array('openssl', 'SHA384'), - 'RS512' => array('openssl', 'SHA512'), - 'EdDSA' => array('sodium_crypto', 'EdDSA'), - ); + public static int $leeway = 0; + + public static array $supported_algs = [ + 'ES384' => ['openssl', 'SHA384'], + 'ES256' => ['openssl', 'SHA256'], + 'HS256' => ['hash_hmac', 'SHA256'], + 'HS384' => ['hash_hmac', 'SHA384'], + 'HS512' => ['hash_hmac', 'SHA512'], + 'RS256' => ['openssl', 'SHA256'], + 'RS384' => ['openssl', 'SHA384'], + 'RS512' => ['openssl', 'SHA512'], + 'EdDSA' => ['sodium_crypto', 'EdDSA'], + ]; /** * 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 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' * - * @return object The JWT's payload as a PHP object + * @return stdClass The JWT's payload as a PHP object * * @throws InvalidArgumentException Provided key/key-array was empty * @throws DomainException Provided JWT is malformed @@ -82,10 +72,10 @@ class JWT * @uses jsonDecode * @uses urlsafeB64Decode */ - public static function decode($jwt, $keyOrKeyArray) + public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray): stdClass { // Validate JWT - $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; + $timestamp = \time(); if (empty($keyOrKeyArray)) { throw new InvalidArgumentException('Key may not be empty'); @@ -101,6 +91,9 @@ public static function decode($jwt, $keyOrKeyArray) if (null === $payload = static::jsonDecode(static::urlsafeB64Decode($bodyb64))) { throw new UnexpectedValueException('Invalid claims encoding'); } + if (!$payload instanceof stdClass) { + throw new UnexpectedValueException('Payload must be a JSON object'); + } if (false === ($sig = static::urlsafeB64Decode($cryptob64))) { throw new UnexpectedValueException('Invalid signature encoding'); } @@ -154,30 +147,32 @@ public static function decode($jwt, $keyOrKeyArray) /** * Converts and signs a PHP object or array into a JWT string. * - * @param object|array $payload PHP object or array - * @param string|resource $key The secret key. - * If the algorithm used is asymmetric, this is the private key - * @param string $alg The signing algorithm. - * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', - * 'HS512', 'RS256', 'RS384', and 'RS512' - * @param mixed $keyId - * @param array $head An array with header elements to attach + * @param array $payload PHP array + * @param string|OpenSSLAsymmetricKey $key The secret key. + * If the algorithm used is asymmetric, this is the private key + * @param string $keyId + * @param array $head An array with header elements to attach * * @return string A signed JWT * * @uses jsonEncode * @uses urlsafeB64Encode */ - public static function encode($payload, $key, $alg, $keyId = null, $head = null) - { - $header = array('typ' => 'JWT', 'alg' => $alg); + public static function encode( + array $payload, + string|OpenSSLAsymmetricKey $key, + string $alg, + string $keyId = null, + array $head = null + ): string { + $header = ['typ' => 'JWT', 'alg' => $alg]; if ($keyId !== null) { $header['kid'] = $keyId; } if (isset($head) && \is_array($head)) { $header = \array_merge($head, $header); } - $segments = array(); + $segments = []; $segments[] = static::urlsafeB64Encode(static::jsonEncode($header)); $segments[] = static::urlsafeB64Encode(static::jsonEncode($payload)); $signing_input = \implode('.', $segments); @@ -191,17 +186,16 @@ public static function encode($payload, $key, $alg, $keyId = null, $head = null) /** * Sign a string with a given key and algorithm. * - * @param string $msg The message to sign - * @param string|resource $key The secret key - * @param string $alg The signing algorithm. - * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', - * 'HS512', 'RS256', 'RS384', and 'RS512' + * @param string $msg The message to sign + * @param string|OpenSSLAsymmetricKey $key The secret key. + * @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', + * 'HS512', 'RS256', 'RS384', and 'RS512' * * @return string An encrypted message * * @throws DomainException Unsupported algorithm or bad key was specified */ - public static function sign($msg, $key, $alg) + public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, string $alg): string { if (empty(static::$supported_algs[$alg])) { throw new DomainException('Algorithm not supported'); @@ -241,17 +235,21 @@ public static function sign($msg, $key, $alg) * Verify a signature with the message, key and method. Not all methods * are symmetric, so we must have a separate verify and sign method. * - * @param string $msg The original message (header and body) - * @param string $signature The original signature - * @param string|resource $key For HS*, a string key works. for RS*, must be a resource of an openssl public key - * @param string $alg The algorithm + * @param string $msg The original message (header and body) + * @param string $signature The original signature + * @param string|OpenSSLAsymmetricKey $key For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey + * @param string $alg The algorithm * * @return bool * * @throws DomainException Invalid Algorithm, bad key, or OpenSSL failure */ - private static function verify($msg, $signature, $key, $alg) - { + private static function verify( + string $msg, + string $signature, + string|OpenSSLAsymmetricKey $keyMaterial, + string $alg + ): bool { if (empty(static::$supported_algs[$alg])) { throw new DomainException('Algorithm not supported'); } @@ -259,7 +257,7 @@ private static function verify($msg, $signature, $key, $alg) list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'openssl': - $success = \openssl_verify($msg, $signature, $key, $algorithm); + $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); if ($success === 1) { return true; } elseif ($success === 0) { @@ -275,7 +273,7 @@ private static function verify($msg, $signature, $key, $alg) } try { // The last non-empty line is used as the key. - $lines = array_filter(explode("\n", $key)); + $lines = array_filter(explode("\n", $keyMaterial)); $key = base64_decode(end($lines)); return sodium_crypto_sign_verify_detached($signature, $msg, $key); } catch (Exception $e) { @@ -283,7 +281,7 @@ private static function verify($msg, $signature, $key, $alg) } case 'hash_hmac': default: - $hash = \hash_hmac($algorithm, $msg, $key, true); + $hash = \hash_hmac($algorithm, $msg, $keyMaterial, true); return self::constantTimeEquals($hash, $signature); } } @@ -293,27 +291,13 @@ private static function verify($msg, $signature, $key, $alg) * * @param string $input JSON string * - * @return object Object representation of JSON string + * @return mixed The decoded JSON string * * @throws DomainException Provided string was invalid JSON */ - public static function jsonDecode($input) + public static function jsonDecode(string $input): mixed { - if (\version_compare(PHP_VERSION, '5.4.0', '>=') && !(\defined('JSON_C_VERSION') && PHP_INT_SIZE > 4)) { - /** In PHP >=5.4.0, json_decode() accepts an options parameter, that allows you - * to specify that large ints (like Steam Transaction IDs) should be treated as - * strings, rather than the PHP default behaviour of converting them to floats. - */ - $obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING); - } else { - /** Not all servers will support that, however, so for older versions we must - * manually detect large ints in the JSON string and quote them (thus converting - *them to strings) before decoding, hence the preg_replace() call. - */ - $max_int_length = \strlen((string) PHP_INT_MAX) - 1; - $json_without_bigints = \preg_replace('/:\s*(-?\d{'.$max_int_length.',})/', ': "$1"', $input); - $obj = \json_decode($json_without_bigints); - } + $obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING); if ($errno = \json_last_error()) { static::handleJsonError($errno); @@ -324,15 +308,15 @@ public static function jsonDecode($input) } /** - * Encode a PHP object into a JSON string. + * Encode a PHP array into a JSON string. * - * @param object|array $input A PHP object or array + * @param array $input A PHP array * - * @return string JSON representation of the PHP object or array + * @return string JSON representation of the PHP array * * @throws DomainException Provided object could not be encoded to valid JSON */ - public static function jsonEncode($input) + public static function jsonEncode(array $input): string { if (PHP_VERSION_ID >= 50400) { $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); @@ -355,7 +339,7 @@ public static function jsonEncode($input) * * @return string A decoded string */ - public static function urlsafeB64Decode($input) + public static function urlsafeB64Decode(string $input): string { $remainder = \strlen($input) % 4; if ($remainder) { @@ -372,7 +356,7 @@ public static function urlsafeB64Decode($input) * * @return string The base64 encode of what you passed in */ - public static function urlsafeB64Encode($input) + public static function urlsafeB64Encode(string $input): string { return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_')); } @@ -386,9 +370,9 @@ public static function urlsafeB64Encode($input) * * @throws UnexpectedValueException * - * @return array containing the keyMaterial and algorithm + * @return Key */ - private static function getKey($keyOrKeyArray, $kid = null) + private static function getKey(Key|array|ArrayAccess $keyOrKeyArray, ?string $kid): Key { if ($keyOrKeyArray instanceof Key) { return $keyOrKeyArray; @@ -397,7 +381,7 @@ private static function getKey($keyOrKeyArray, $kid = null) if (is_array($keyOrKeyArray) || $keyOrKeyArray instanceof ArrayAccess) { foreach ($keyOrKeyArray as $keyId => $key) { if (!$key instanceof Key) { - throw new UnexpectedValueException( + throw new TypeError( '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an ' . 'array of Firebase\JWT\Key keys' ); @@ -424,7 +408,7 @@ private static function getKey($keyOrKeyArray, $kid = null) * @param string $right The user-supplied string * @return bool */ - public static function constantTimeEquals($left, $right) + public static function constantTimeEquals(string $left, string $right): bool { if (\function_exists('hash_equals')) { return \hash_equals($left, $right); @@ -445,17 +429,19 @@ public static function constantTimeEquals($left, $right) * * @param int $errno An error number from json_last_error() * + * @throws DomainException + * * @return void */ - private static function handleJsonError($errno) + private static function handleJsonError(int $errno): void { - $messages = array( + $messages = [ JSON_ERROR_DEPTH => 'Maximum stack depth exceeded', JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON', JSON_ERROR_CTRL_CHAR => 'Unexpected control character found', JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON', JSON_ERROR_UTF8 => 'Malformed UTF-8 characters' //PHP >= 5.3.3 - ); + ]; throw new DomainException( isset($messages[$errno]) ? $messages[$errno] @@ -470,7 +456,7 @@ private static function handleJsonError($errno) * * @return int */ - private static function safeStrlen($str) + private static function safeStrlen(string $str): int { if (\function_exists('mb_strlen')) { return \mb_strlen($str, '8bit'); @@ -484,7 +470,7 @@ private static function safeStrlen($str) * @param string $sig The ECDSA signature to convert * @return string The encoded DER object */ - private static function signatureToDER($sig) + private static function signatureToDER(string $sig): string { // Separate the signature into r-value and s-value list($r, $s) = \str_split($sig, (int) (\strlen($sig) / 2)); @@ -503,9 +489,9 @@ private static function signatureToDER($sig) } return self::encodeDER( - self::$asn1Sequence, - self::encodeDER(self::$asn1Integer, $r) . - self::encodeDER(self::$asn1Integer, $s) + self::ASN1_SEQUENCE, + self::encodeDER(self::ASN1_INTEGER, $r) . + self::encodeDER(self::ASN1_INTEGER, $s) ); } @@ -514,12 +500,13 @@ private static function signatureToDER($sig) * * @param int $type DER tag * @param string $value the value to encode + * * @return string the encoded object */ - private static function encodeDER($type, $value) + private static function encodeDER(int $type, string $value): string { $tag_header = 0; - if ($type === self::$asn1Sequence) { + if ($type === self::ASN1_SEQUENCE) { $tag_header |= 0x20; } @@ -537,9 +524,10 @@ private static function encodeDER($type, $value) * * @param string $der binary signature in DER format * @param int $keySize the number of bits in the key + * * @return string the signature */ - private static function signatureFromDER($der, $keySize) + private static function signatureFromDER(string $der, int $keySize): string { // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE list($offset, $_) = self::readDER($der); @@ -564,9 +552,10 @@ private static function signatureFromDER($der, $keySize) * @param string $der the binary data in DER format * @param int $offset the offset of the data stream containing the object * to decode + * * @return array [$offset, $data] the new offset and the decoded object */ - private static function readDER($der, $offset = 0) + private static function readDER(string $der, int $offset = 0): array { $pos = $offset; $size = \strlen($der); @@ -584,7 +573,7 @@ private static function readDER($der, $offset = 0) } // Value - if ($type == self::$asn1BitString) { + if ($type == self::ASN1_BIT_STRING) { $pos++; // Skip the first contents octet (padding indicator) $data = \substr($der, $pos, $len - 1); $pos += $len - 1; @@ -595,6 +584,6 @@ private static function readDER($der, $offset = 0) $data = null; } - return array($pos, $data); + return [$pos, $data]; } } diff --git a/src/Key.php b/src/Key.php index f1ede6f2..286a3143 100644 --- a/src/Key.php +++ b/src/Key.php @@ -2,41 +2,34 @@ namespace Firebase\JWT; -use InvalidArgumentException; use OpenSSLAsymmetricKey; +use TypeError; +use InvalidArgumentException; class Key { - /** @var string $algorithm */ - private $algorithm; - - /** @var string|resource|OpenSSLAsymmetricKey $keyMaterial */ - private $keyMaterial; - /** - * @param string|resource|OpenSSLAsymmetricKey $keyMaterial + * @param string|OpenSSLAsymmetricKey $keyMaterial * @param string $algorithm */ - public function __construct($keyMaterial, $algorithm) - { + public function __construct( + private string|OpenSSLAsymmetricKey $keyMaterial, + private string $algorithm + ) { if ( !is_string($keyMaterial) - && !is_resource($keyMaterial) && !$keyMaterial instanceof OpenSSLAsymmetricKey ) { - throw new InvalidArgumentException('Type error: $keyMaterial must be a string, resource, or OpenSSLAsymmetricKey'); + throw new TypeError('Key material must be a string or OpenSSLAsymmetricKey'); } if (empty($keyMaterial)) { - throw new InvalidArgumentException('Type error: $keyMaterial must not be empty'); + throw new InvalidArgumentException('Key material must not be empty'); } - if (!is_string($algorithm)|| empty($keyMaterial)) { - throw new InvalidArgumentException('Type error: $algorithm must be a string'); + if (empty($algorithm)) { + throw new InvalidArgumentException('Algorithm must not be empty'); } - - $this->keyMaterial = $keyMaterial; - $this->algorithm = $algorithm; } /** @@ -44,15 +37,15 @@ public function __construct($keyMaterial, $algorithm) * * @return string */ - public function getAlgorithm() + public function getAlgorithm(): string { return $this->algorithm; } /** - * @return string|resource|OpenSSLAsymmetricKey + * @return string|OpenSSLAsymmetricKey */ - public function getKeyMaterial() + public function getKeyMaterial(): mixed { return $this->keyMaterial; } diff --git a/tests/JWKTest.php b/tests/JWKTest.php index c580f40f..d80f6c4b 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -3,6 +3,8 @@ namespace Firebase\JWT; use PHPUnit\Framework\TestCase; +use InvalidArgumentException; +use UnexpectedValueException; class JWKTest extends TestCase { @@ -13,29 +15,29 @@ class JWKTest extends TestCase public function testMissingKty() { $this->setExpectedException( - 'UnexpectedValueException', + UnexpectedValueException::class, 'JWK must contain a "kty" parameter' ); - $badJwk = array('kid' => 'foo'); - $keys = JWK::parseKeySet(array('keys' => array($badJwk))); + $badJwk = ['kid' => 'foo']; + $keys = JWK::parseKeySet(['keys' => [$badJwk]]); } public function testInvalidAlgorithm() { $this->setExpectedException( - 'UnexpectedValueException', + UnexpectedValueException::class, 'No supported algorithms found in JWK Set' ); - $badJwk = array('kty' => 'BADALG', 'alg' => 'RSA256'); - $keys = JWK::parseKeySet(array('keys' => array($badJwk))); + $badJwk = ['kty' => 'BADTYPE', 'alg' => 'RSA256']; + $keys = JWK::parseKeySet(['keys' => [$badJwk]]); } public function testParsePrivateKey() { $this->setExpectedException( - 'UnexpectedValueException', + UnexpectedValueException::class, 'RSA private keys are not supported' ); @@ -51,7 +53,7 @@ public function testParsePrivateKey() public function testParsePrivateKeyWithoutAlg() { $this->setExpectedException( - 'UnexpectedValueException', + UnexpectedValueException::class, 'JWK must contain an "alg" parameter' ); @@ -92,16 +94,16 @@ public function testParseJwkKeySet() public function testParseJwkKey_empty() { - $this->setExpectedException('InvalidArgumentException', 'JWK must not be empty'); + $this->setExpectedException(InvalidArgumentException::class, 'JWK must not be empty'); - JWK::parseKeySet(array('keys' => array(array()))); + JWK::parseKeySet(['keys' => [[]]]); } public function testParseJwkKeySet_empty() { - $this->setExpectedException('InvalidArgumentException', 'JWK Set did not contain any keys'); + $this->setExpectedException(InvalidArgumentException::class, 'JWK Set did not contain any keys'); - JWK::parseKeySet(array('keys' => array())); + JWK::parseKeySet(['keys' => []]); } /** @@ -110,12 +112,12 @@ public function testParseJwkKeySet_empty() public function testDecodeByJwkKeySetTokenExpired() { $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); - $payload = array('exp' => strtotime('-1 hour')); + $payload = ['exp' => strtotime('-1 hour')]; $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); - $this->setExpectedException('Firebase\JWT\ExpiredException'); + $this->setExpectedException(ExpiredException::class); - JWT::decode($msg, self::$keys, array('RS256')); + JWT::decode($msg, self::$keys); } /** @@ -124,10 +126,10 @@ public function testDecodeByJwkKeySetTokenExpired() public function testDecodeByJwkKeySet() { $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); - $payload = array('sub' => 'foo', 'exp' => strtotime('+10 seconds')); + $payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')]; $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); - $result = JWT::decode($msg, self::$keys, array('RS256')); + $result = JWT::decode($msg, self::$keys); $this->assertEquals("foo", $result->sub); } @@ -138,10 +140,10 @@ public function testDecodeByJwkKeySet() public function testDecodeByMultiJwkKeySet() { $privKey2 = file_get_contents(__DIR__ . '/data/rsa2-private.pem'); - $payload = array('sub' => 'bar', 'exp' => strtotime('+10 seconds')); + $payload = ['sub' => 'bar', 'exp' => strtotime('+10 seconds')]; $msg = JWT::encode($payload, $privKey2, 'RS256', 'jwk2'); - $result = JWT::decode($msg, self::$keys, array('RS256')); + $result = JWT::decode($msg, self::$keys); $this->assertEquals("bar", $result->sub); } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 36e2095e..aa5ce140 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -4,6 +4,10 @@ use ArrayObject; use PHPUnit\Framework\TestCase; +use DomainException; +use InvalidArgumentException; +use UnexpectedValueException; +use stdClass; class JWTTest extends TestCase { @@ -19,68 +23,61 @@ public function setExpectedException($exceptionName, $message = '', $code = null } } - public function testDecodeFromPython() - { - $msg = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.Iio6aHR0cDovL2FwcGxpY2F0aW9uL2NsaWNreT9ibGFoPTEuMjMmZi5vbz00NTYgQUMwMDAgMTIzIg.E_U8X2YpMT5K1cEiT_3-IvBYfrdIFIeVYeOqre_Z5Cg'; - $this->assertEquals( - JWT::decode($msg, new Key('my_key', 'HS256')), - '*:http://application/clicky?blah=1.23&f.oo=456 AC000 123' - ); - } - public function testUrlSafeCharacters() { - $encoded = JWT::encode('f?', 'a', 'HS256'); - $this->assertEquals('f?', JWT::decode($encoded, new Key('a', 'HS256'))); + $encoded = JWT::encode(['message' => 'f?'], 'a', 'HS256'); + $expected = new stdClass(); + $expected->message = 'f?'; + $this->assertEquals($expected, JWT::decode($encoded, new Key('a', 'HS256'))); } public function testMalformedUtf8StringsFail() { - $this->setExpectedException('DomainException'); - JWT::encode(pack('c', 128), 'a', 'HS256'); + $this->setExpectedException(DomainException::class); + JWT::encode(['message' => pack('c', 128)], 'a', 'HS256'); } public function testMalformedJsonThrowsException() { - $this->setExpectedException('DomainException'); + $this->setExpectedException(DomainException::class); JWT::jsonDecode('this is not valid JSON string'); } public function testExpiredToken() { - $this->setExpectedException('Firebase\JWT\ExpiredException'); - $payload = array( + $this->setExpectedException(ExpiredException::class); + $payload = [ "message" => "abc", - "exp" => time() - 20); // time in the past + "exp" => time() - 20]; // time in the past $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); } public function testBeforeValidTokenWithNbf() { - $this->setExpectedException('Firebase\JWT\BeforeValidException'); - $payload = array( + $this->setExpectedException(BeforeValidException::class); + $payload = [ "message" => "abc", - "nbf" => time() + 20); // time in the future + "nbf" => time() + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); } public function testBeforeValidTokenWithIat() { - $this->setExpectedException('Firebase\JWT\BeforeValidException'); - $payload = array( + $this->setExpectedException(BeforeValidException::class); + $payload = [ "message" => "abc", - "iat" => time() + 20); // time in the future + "iat" => time() + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); } public function testValidToken() { - $payload = array( + $payload = [ "message" => "abc", - "exp" => time() + JWT::$leeway + 20); // time in the future + "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'); @@ -89,9 +86,9 @@ public function testValidToken() public function testValidTokenWithLeeway() { JWT::$leeway = 60; - $payload = array( + $payload = [ "message" => "abc", - "exp" => time() - 20); // time in the past + "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'); @@ -101,10 +98,10 @@ public function testValidTokenWithLeeway() public function testExpiredTokenWithLeeway() { JWT::$leeway = 60; - $payload = array( + $payload = [ "message" => "abc", - "exp" => time() - 70); // time far in the past - $this->setExpectedException('Firebase\JWT\ExpiredException'); + "exp" => time() - 70]; // time far in the past + $this->setExpectedException(ExpiredException::class); $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -113,11 +110,11 @@ public function testExpiredTokenWithLeeway() public function testValidTokenWithNbf() { - $payload = array( + $payload = [ "message" => "abc", "iat" => time(), "exp" => time() + 20, // time in the future - "nbf" => time() - 20); + "nbf" => time() - 20]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -126,9 +123,9 @@ public function testValidTokenWithNbf() public function testValidTokenWithNbfLeeway() { JWT::$leeway = 60; - $payload = array( + $payload = [ "message" => "abc", - "nbf" => time() + 20); // not before in near (leeway) future + "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'); @@ -138,11 +135,11 @@ public function testValidTokenWithNbfLeeway() public function testInvalidTokenWithNbfLeeway() { JWT::$leeway = 60; - $payload = array( + $payload = [ "message" => "abc", - "nbf" => time() + 65); // not before too far in future + "nbf" => time() + 65]; // not before too far in future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException('Firebase\JWT\BeforeValidException'); + $this->setExpectedException(BeforeValidException::class); JWT::decode($encoded, new Key('my_key', 'HS256')); JWT::$leeway = 0; } @@ -150,9 +147,9 @@ public function testInvalidTokenWithNbfLeeway() public function testValidTokenWithIatLeeway() { JWT::$leeway = 60; - $payload = array( + $payload = [ "message" => "abc", - "iat" => time() + 20); // issued in near (leeway) future + "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'); @@ -162,123 +159,133 @@ public function testValidTokenWithIatLeeway() public function testInvalidTokenWithIatLeeway() { JWT::$leeway = 60; - $payload = array( + $payload = [ "message" => "abc", - "iat" => time() + 65); // issued too far in future + "iat" => time() + 65]; // issued too far in future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException('Firebase\JWT\BeforeValidException'); + $this->setExpectedException(BeforeValidException::class); JWT::decode($encoded, new Key('my_key', 'HS256')); JWT::$leeway = 0; } public function testInvalidToken() { - $payload = array( + $payload = [ "message" => "abc", - "exp" => time() + 20); // time in the future + "exp" => time() + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException('Firebase\JWT\SignatureInvalidException'); + $this->setExpectedException(SignatureInvalidException::class); JWT::decode($encoded, new Key('my_key2', 'HS256')); } public function testNullKeyFails() { - $payload = array( + $payload = [ "message" => "abc", - "exp" => time() + JWT::$leeway + 20); // time in the future + "exp" => time() + JWT::$leeway + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException('InvalidArgumentException'); + $this->setExpectedException('TypeError'); JWT::decode($encoded, new Key(null, 'HS256')); } public function testEmptyKeyFails() { - $payload = array( + $payload = [ "message" => "abc", - "exp" => time() + JWT::$leeway + 20); // time in the future + "exp" => time() + JWT::$leeway + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException('InvalidArgumentException'); + $this->setExpectedException(InvalidArgumentException::class); JWT::decode($encoded, new Key('', 'HS256')); } public function testKIDChooser() { - $keys = array( + $keys = [ '1' => new Key('my_key', 'HS256'), '2' => new Key('my_key2', 'HS256') - ); - $msg = JWT::encode('abc', $keys['1']->getKeyMaterial(), 'HS256', '1'); + ]; + $msg = JWT::encode(['message' => 'abc'], $keys['1']->getKeyMaterial(), 'HS256', '1'); $decoded = JWT::decode($msg, $keys); - $this->assertEquals($decoded, 'abc'); + $expected = new stdClass(); + $expected->message = 'abc'; + $this->assertEquals($decoded, $expected); } public function testArrayAccessKIDChooser() { - $keys = new ArrayObject(array( + $keys = new ArrayObject([ '1' => new Key('my_key', 'HS256'), '2' => new Key('my_key2', 'HS256'), - )); - $msg = JWT::encode('abc', $keys['1']->getKeyMaterial(), 'HS256', '1'); + ]); + $msg = JWT::encode(['message' => 'abc'], $keys['1']->getKeyMaterial(), 'HS256', '1'); $decoded = JWT::decode($msg, $keys); - $this->assertEquals($decoded, 'abc'); + $expected = new stdClass(); + $expected->message = 'abc'; + $this->assertEquals($decoded, $expected); } public function testNoneAlgorithm() { - $msg = JWT::encode('abc', 'my_key', 'HS256'); - $this->setExpectedException('UnexpectedValueException'); + $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $this->setExpectedException(UnexpectedValueException::class); JWT::decode($msg, new Key('my_key', 'none')); } public function testIncorrectAlgorithm() { - $msg = JWT::encode('abc', 'my_key', 'HS256'); - $this->setExpectedException('UnexpectedValueException'); + $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $this->setExpectedException(UnexpectedValueException::class); JWT::decode($msg, new Key('my_key', 'RS256')); } public function testEmptyAlgorithm() { - $msg = JWT::encode('abc', 'my_key', 'HS256'); - $this->setExpectedException('UnexpectedValueException'); + $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $this->setExpectedException(InvalidArgumentException::class); JWT::decode($msg, new Key('my_key', '')); } public function testAdditionalHeaders() { - $msg = JWT::encode('abc', 'my_key', 'HS256', null, array('cty' => 'test-eit;v=1')); - $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), 'abc'); + $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256', null, ['cty' => 'test-eit;v=1']); + $expected = new stdClass(); + $expected->message = 'abc'; + $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), $expected); } public function testInvalidSegmentCount() { - $this->setExpectedException('UnexpectedValueException'); + $this->setExpectedException(UnexpectedValueException::class); JWT::decode('brokenheader.brokenbody', new Key('my_key', 'HS256')); } public function testInvalidSignatureEncoding() { $msg = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6ImZvbyJ9.Q4Kee9E8o0Xfo4ADXvYA8t7dN_X_bU9K5w6tXuiSjlUxx"; - $this->setExpectedException('UnexpectedValueException'); + $this->setExpectedException(UnexpectedValueException::class); JWT::decode($msg, new Key('secret', 'HS256')); } public function testHSEncodeDecode() { - $msg = JWT::encode('abc', 'my_key', 'HS256'); - $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), 'abc'); + $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $expected = new stdClass(); + $expected->message = 'abc'; + $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), $expected); } public function testRSEncodeDecode() { - $privKey = openssl_pkey_new(array('digest_alg' => 'sha256', + $privKey = openssl_pkey_new(['digest_alg' => 'sha256', 'private_key_bits' => 1024, - 'private_key_type' => OPENSSL_KEYTYPE_RSA)); - $msg = JWT::encode('abc', $privKey, 'RS256'); + 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + $msg = JWT::encode(['message' => 'abc'], $privKey, 'RS256'); $pubKey = openssl_pkey_get_details($privKey); $pubKey = $pubKey['key']; $decoded = JWT::decode($msg, new Key($pubKey, 'RS256')); - $this->assertEquals($decoded, 'abc'); + $expected = new stdClass(); + $expected->message = 'abc'; + $this->assertEquals($decoded, $expected); } public function testEdDsaEncodeDecode() @@ -286,7 +293,7 @@ public function testEdDsaEncodeDecode() $keyPair = sodium_crypto_sign_keypair(); $privKey = base64_encode(sodium_crypto_sign_secretkey($keyPair)); - $payload = array('foo' => 'bar'); + $payload = ['foo' => 'bar']; $msg = JWT::encode($payload, $privKey, 'EdDSA'); $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); @@ -299,13 +306,13 @@ public function testInvalidEdDsaEncodeDecode() $keyPair = sodium_crypto_sign_keypair(); $privKey = base64_encode(sodium_crypto_sign_secretkey($keyPair)); - $payload = array('foo' => 'bar'); + $payload = ['foo' => 'bar']; $msg = JWT::encode($payload, $privKey, 'EdDSA'); // Generate a different key. $keyPair = sodium_crypto_sign_keypair(); $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); - $this->setExpectedException('Firebase\JWT\SignatureInvalidException'); + $this->setExpectedException(SignatureInvalidException::class); JWT::decode($msg, new Key($pubKey, 'EdDSA')); } @@ -316,11 +323,13 @@ public function testRSEncodeDecodeWithPassphrase() 'passphrase' ); - $jwt = JWT::encode('abc', $privateKey, 'RS256'); + $jwt = JWT::encode(['message' => 'abc'], $privateKey, 'RS256'); $keyDetails = openssl_pkey_get_details($privateKey); $pubKey = $keyDetails['key']; $decoded = JWT::decode($jwt, new Key($pubKey, 'RS256')); - $this->assertEquals($decoded, 'abc'); + $expected = new stdClass(); + $expected->message = 'abc'; + $this->assertEquals($decoded, $expected); } /** @@ -330,7 +339,7 @@ public function testRSEncodeDecodeWithPassphrase() public function testEncodeDecode($privateKeyFile, $publicKeyFile, $alg) { $privateKey = file_get_contents($privateKeyFile); - $payload = array('foo' => 'bar'); + $payload = ['foo' => 'bar']; $encoded = JWT::encode($payload, $privateKey, $alg); // Verify decoding succeeds @@ -342,12 +351,12 @@ public function testEncodeDecode($privateKeyFile, $publicKeyFile, $alg) public function provideEncodeDecode() { - return array( - array(__DIR__ . '/data/ecdsa-private.pem', __DIR__ . '/data/ecdsa-public.pem', 'ES256'), - array(__DIR__ . '/data/ecdsa384-private.pem', __DIR__ . '/data/ecdsa384-public.pem', 'ES384'), - array(__DIR__ . '/data/rsa1-private.pem', __DIR__ . '/data/rsa1-public.pub', 'RS512'), - array(__DIR__ . '/data/ed25519-1.sec', __DIR__ . '/data/ed25519-1.pub', 'EdDSA'), - ); + return [ + [__DIR__ . '/data/ecdsa-private.pem', __DIR__ . '/data/ecdsa-public.pem', 'ES256'], + [__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'], + ]; } public function testEncodeDecodeWithResource() @@ -356,7 +365,7 @@ public function testEncodeDecodeWithResource() $resource = openssl_pkey_get_public($pem); $privateKey = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); - $payload = array('foo' => 'bar'); + $payload = ['foo' => 'bar']; $encoded = JWT::encode($payload, $privateKey, 'RS512'); // Verify decoding succeeds From 958938bd9925372b79b04e42e2012bb08033e2fe Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 16 Feb 2022 06:33:52 -0800 Subject: [PATCH 029/121] chore: remove phpunit shim (#401) --- tests/JWKTest.php | 47 +++++++++++++---------------------------------- tests/JWTTest.php | 47 ++++++++++++++++++----------------------------- 2 files changed, 31 insertions(+), 63 deletions(-) diff --git a/tests/JWKTest.php b/tests/JWKTest.php index d80f6c4b..17dd4a62 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -14,10 +14,8 @@ class JWKTest extends TestCase public function testMissingKty() { - $this->setExpectedException( - UnexpectedValueException::class, - 'JWK must contain a "kty" parameter' - ); + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('JWK must contain a "kty" parameter'); $badJwk = ['kid' => 'foo']; $keys = JWK::parseKeySet(['keys' => [$badJwk]]); @@ -25,10 +23,8 @@ public function testMissingKty() public function testInvalidAlgorithm() { - $this->setExpectedException( - UnexpectedValueException::class, - 'No supported algorithms found in JWK Set' - ); + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('No supported algorithms found in JWK Set'); $badJwk = ['kty' => 'BADTYPE', 'alg' => 'RSA256']; $keys = JWK::parseKeySet(['keys' => [$badJwk]]); @@ -36,10 +32,8 @@ public function testInvalidAlgorithm() public function testParsePrivateKey() { - $this->setExpectedException( - UnexpectedValueException::class, - 'RSA private keys are not supported' - ); + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('RSA private keys are not supported'); $jwkSet = json_decode( file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), @@ -52,10 +46,8 @@ public function testParsePrivateKey() public function testParsePrivateKeyWithoutAlg() { - $this->setExpectedException( - UnexpectedValueException::class, - 'JWK must contain an "alg" parameter' - ); + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('JWK must contain an "alg" parameter'); $jwkSet = json_decode( file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), @@ -94,14 +86,16 @@ public function testParseJwkKeySet() public function testParseJwkKey_empty() { - $this->setExpectedException(InvalidArgumentException::class, 'JWK must not be empty'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('JWK must not be empty'); JWK::parseKeySet(['keys' => [[]]]); } public function testParseJwkKeySet_empty() { - $this->setExpectedException(InvalidArgumentException::class, 'JWK Set did not contain any keys'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('JWK Set did not contain any keys'); JWK::parseKeySet(['keys' => []]); } @@ -115,7 +109,7 @@ public function testDecodeByJwkKeySetTokenExpired() $payload = ['exp' => strtotime('-1 hour')]; $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); - $this->setExpectedException(ExpiredException::class); + $this->expectException(ExpiredException::class); JWT::decode($msg, self::$keys); } @@ -147,19 +141,4 @@ public function testDecodeByMultiJwkKeySet() $this->assertEquals("bar", $result->sub); } - - /* - * For compatibility with PHPUnit 4.8 and PHP < 5.6 - */ - public function setExpectedException($exceptionName, $message = '', $code = null) - { - if (method_exists($this, 'expectException')) { - $this->expectException($exceptionName); - if ($message) { - $this->expectExceptionMessage($message); - } - } else { - parent::setExpectedException($exceptionName, $message, $code); - } - } } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index aa5ce140..8b23ad6b 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -6,23 +6,12 @@ use PHPUnit\Framework\TestCase; use DomainException; use InvalidArgumentException; +use TypeError; use UnexpectedValueException; use stdClass; class JWTTest extends TestCase { - /* - * For compatibility with PHPUnit 4.8 and PHP < 5.6 - */ - public function setExpectedException($exceptionName, $message = '', $code = null) - { - if (method_exists($this, 'expectException')) { - $this->expectException($exceptionName); - } else { - parent::setExpectedException($exceptionName, $message, $code); - } - } - public function testUrlSafeCharacters() { $encoded = JWT::encode(['message' => 'f?'], 'a', 'HS256'); @@ -33,19 +22,19 @@ public function testUrlSafeCharacters() public function testMalformedUtf8StringsFail() { - $this->setExpectedException(DomainException::class); + $this->expectException(DomainException::class); JWT::encode(['message' => pack('c', 128)], 'a', 'HS256'); } public function testMalformedJsonThrowsException() { - $this->setExpectedException(DomainException::class); + $this->expectException(DomainException::class); JWT::jsonDecode('this is not valid JSON string'); } public function testExpiredToken() { - $this->setExpectedException(ExpiredException::class); + $this->expectException(ExpiredException::class); $payload = [ "message" => "abc", "exp" => time() - 20]; // time in the past @@ -55,7 +44,7 @@ public function testExpiredToken() public function testBeforeValidTokenWithNbf() { - $this->setExpectedException(BeforeValidException::class); + $this->expectException(BeforeValidException::class); $payload = [ "message" => "abc", "nbf" => time() + 20]; // time in the future @@ -65,7 +54,7 @@ public function testBeforeValidTokenWithNbf() public function testBeforeValidTokenWithIat() { - $this->setExpectedException(BeforeValidException::class); + $this->expectException(BeforeValidException::class); $payload = [ "message" => "abc", "iat" => time() + 20]; // time in the future @@ -101,7 +90,7 @@ public function testExpiredTokenWithLeeway() $payload = [ "message" => "abc", "exp" => time() - 70]; // time far in the past - $this->setExpectedException(ExpiredException::class); + $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'); @@ -139,7 +128,7 @@ public function testInvalidTokenWithNbfLeeway() "message" => "abc", "nbf" => time() + 65]; // not before too far in future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException(BeforeValidException::class); + $this->expectException(BeforeValidException::class); JWT::decode($encoded, new Key('my_key', 'HS256')); JWT::$leeway = 0; } @@ -163,7 +152,7 @@ public function testInvalidTokenWithIatLeeway() "message" => "abc", "iat" => time() + 65]; // issued too far in future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException(BeforeValidException::class); + $this->expectException(BeforeValidException::class); JWT::decode($encoded, new Key('my_key', 'HS256')); JWT::$leeway = 0; } @@ -174,7 +163,7 @@ public function testInvalidToken() "message" => "abc", "exp" => time() + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException(SignatureInvalidException::class); + $this->expectException(SignatureInvalidException::class); JWT::decode($encoded, new Key('my_key2', 'HS256')); } @@ -184,7 +173,7 @@ public function testNullKeyFails() "message" => "abc", "exp" => time() + JWT::$leeway + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException('TypeError'); + $this->expectException(TypeError::class); JWT::decode($encoded, new Key(null, 'HS256')); } @@ -194,7 +183,7 @@ public function testEmptyKeyFails() "message" => "abc", "exp" => time() + JWT::$leeway + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException(InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); JWT::decode($encoded, new Key('', 'HS256')); } @@ -227,21 +216,21 @@ public function testArrayAccessKIDChooser() public function testNoneAlgorithm() { $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); - $this->setExpectedException(UnexpectedValueException::class); + $this->expectException(UnexpectedValueException::class); JWT::decode($msg, new Key('my_key', 'none')); } public function testIncorrectAlgorithm() { $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); - $this->setExpectedException(UnexpectedValueException::class); + $this->expectException(UnexpectedValueException::class); JWT::decode($msg, new Key('my_key', 'RS256')); } public function testEmptyAlgorithm() { $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); - $this->setExpectedException(InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); JWT::decode($msg, new Key('my_key', '')); } @@ -255,14 +244,14 @@ public function testAdditionalHeaders() public function testInvalidSegmentCount() { - $this->setExpectedException(UnexpectedValueException::class); + $this->expectException(UnexpectedValueException::class); JWT::decode('brokenheader.brokenbody', new Key('my_key', 'HS256')); } public function testInvalidSignatureEncoding() { $msg = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6ImZvbyJ9.Q4Kee9E8o0Xfo4ADXvYA8t7dN_X_bU9K5w6tXuiSjlUxx"; - $this->setExpectedException(UnexpectedValueException::class); + $this->expectException(UnexpectedValueException::class); JWT::decode($msg, new Key('secret', 'HS256')); } @@ -312,7 +301,7 @@ public function testInvalidEdDsaEncodeDecode() // Generate a different key. $keyPair = sodium_crypto_sign_keypair(); $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); - $this->setExpectedException(SignatureInvalidException::class); + $this->expectException(SignatureInvalidException::class); JWT::decode($msg, new Key($pubKey, 'EdDSA')); } From e41b22aff584489f1faf79d290b1f41b52950578 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 16 Feb 2022 10:44:52 -0800 Subject: [PATCH 030/121] chore: add array casting to README (#402) --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 2300ae30..d489c69f 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,22 @@ $jwks = ['keys' => []]; JWT::decode($payload, JWK::parseKeySet($jwks)); ``` +Miscellaneous +------------- + +#### Casting to array + +The return value of `JWT::decode` is the generic PHP object `stdClass`. If you'd like to handle with arrays +instead, you can do the following: + +```php +// return type is stdClass +$decoded = JWT::decode($payload, $keys); + +// cast to array +$decoded = json_decode(json_encode($decoded), true); +``` + Changelog --------- From 13082f8ed314b8380e3596648a3a1f5d502e5b07 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 17 Feb 2022 06:42:40 -0800 Subject: [PATCH 031/121] chore: add phpstan for static analysis (#406) --- .github/workflows/tests.yml | 14 ++++++++++++++ src/JWT.php | 3 +++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 873eae24..80063a00 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,3 +41,17 @@ jobs: 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 + + staticanalysis: + runs-on: ubuntu-latest + name: PHPStan Static Analysis + steps: + - uses: actions/checkout@v2 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.0' + - name: Run Script + run: | + composer global require phpstan/phpstan + ~/.composer/vendor/bin/phpstan analyse src diff --git a/src/JWT.php b/src/JWT.php index f5852dcd..b725aae4 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -7,6 +7,7 @@ use Exception; use InvalidArgumentException; use OpenSSLAsymmetricKey; +use TypeError; use UnexpectedValueException; use DateTime; use stdClass; @@ -229,6 +230,8 @@ public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, strin throw new DomainException($e->getMessage(), 0, $e); } } + + throw new DomainException('Algorithm not supported'); } /** From 92aa12d73dfbf4ee157cf4d01c5342869dcd9d56 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 23 Feb 2022 07:10:42 -0800 Subject: [PATCH 032/121] chore: update library to phpstan level 7 (#407) --- .github/workflows/tests.yml | 2 +- phpstan.neon.dist | 5 ++ src/JWK.php | 20 ++++-- src/JWT.php | 126 ++++++++++++++++++++---------------- src/Key.php | 16 ++--- 5 files changed, 96 insertions(+), 73 deletions(-) create mode 100644 phpstan.neon.dist diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 80063a00..81630645 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,4 +54,4 @@ jobs: - name: Run Script run: | composer global require phpstan/phpstan - ~/.composer/vendor/bin/phpstan analyse src + ~/.composer/vendor/bin/phpstan analyse diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 00000000..56aeebfb --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,5 @@ +parameters: + level: 7 + paths: + - src + treatPhpDocTypesAsCertain: false diff --git a/src/JWK.php b/src/JWK.php index 5663c948..d5ad93a8 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -23,7 +23,7 @@ class JWK /** * Parse a set of JWK keys * - * @param array $jwks The JSON Web Key Set as an associative array + * @param array $jwks The JSON Web Key Set as an associative array * * @return array An associative array of key IDs (kid) to Key objects * @@ -48,7 +48,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)) { - $keys[$kid] = $key; + $keys[(string) $kid] = $key; } } @@ -62,7 +62,7 @@ public static function parseKeySet(array $jwks): array /** * Parse a JWK key * - * @param array $jwk An individual JWK + * @param array $jwk An individual JWK * * @return Key The key object for the JWK * @@ -124,10 +124,16 @@ public static function parseKey(array $jwk): ?Key * * @uses encodeLength */ - private static function createPemFromModulusAndExponent($n, $e) - { - $modulus = JWT::urlsafeB64Decode($n); - $publicExponent = JWT::urlsafeB64Decode($e); + private static function createPemFromModulusAndExponent( + string $n, + string $e + ): string { + if (false === ($modulus = JWT::urlsafeB64Decode($n))) { + throw new UnexpectedValueException('Invalid JWK encoding'); + } + if (false === ($publicExponent = JWT::urlsafeB64Decode($e))) { + throw new UnexpectedValueException('Invalid header encoding'); + } $components = [ 'modulus' => \pack('Ca*a*', 2, self::encodeLength(\strlen($modulus)), $modulus), diff --git a/src/JWT.php b/src/JWT.php index b725aae4..cf58fd2a 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -7,6 +7,7 @@ use Exception; use InvalidArgumentException; use OpenSSLAsymmetricKey; +use OpenSSLCertificate; use TypeError; use UnexpectedValueException; use DateTime; @@ -38,6 +39,9 @@ class JWT */ public static int $leeway = 0; + /** + * @var array + */ public static array $supported_algs = [ 'ES384' => ['openssl', 'SHA384'], 'ES256' => ['openssl', 'SHA256'], @@ -86,10 +90,16 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray) throw new UnexpectedValueException('Wrong number of segments'); } list($headb64, $bodyb64, $cryptob64) = $tks; - if (null === ($header = static::jsonDecode(static::urlsafeB64Decode($headb64)))) { + if (false === ($headerRaw = static::urlsafeB64Decode($headb64))) { + throw new UnexpectedValueException('Invalid header encoding'); + } + if (null === ($header = static::jsonDecode($headerRaw))) { throw new UnexpectedValueException('Invalid header encoding'); } - if (null === $payload = static::jsonDecode(static::urlsafeB64Decode($bodyb64))) { + if (false === ($payloadRaw = static::urlsafeB64Decode($bodyb64))) { + throw new UnexpectedValueException('Invalid claims encoding'); + } + if (null === ($payload = static::jsonDecode($payloadRaw))) { throw new UnexpectedValueException('Invalid claims encoding'); } if (!$payload instanceof stdClass) { @@ -116,7 +126,7 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray) // OpenSSL expects an ASN.1 DER sequence for ES256/ES384 signatures $sig = self::signatureToDER($sig); } - if (!static::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) { + if (!self::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) { throw new SignatureInvalidException('Signature verification failed'); } @@ -148,11 +158,10 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray) /** * Converts and signs a PHP object or array into a JWT string. * - * @param array $payload PHP array - * @param string|OpenSSLAsymmetricKey $key The secret key. - * If the algorithm used is asymmetric, this is the private key - * @param string $keyId - * @param array $head An array with header elements to attach + * @param array $payload PHP array + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key The secret key. + * @param string $keyId + * @param array $head An array with header elements to attach * * @return string A signed JWT * @@ -161,7 +170,7 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray) */ public static function encode( array $payload, - string|OpenSSLAsymmetricKey $key, + string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key, string $alg, string $keyId = null, array $head = null @@ -174,8 +183,8 @@ public static function encode( $header = \array_merge($head, $header); } $segments = []; - $segments[] = static::urlsafeB64Encode(static::jsonEncode($header)); - $segments[] = static::urlsafeB64Encode(static::jsonEncode($payload)); + $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($header)); + $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($payload)); $signing_input = \implode('.', $segments); $signature = static::sign($signing_input, $key, $alg); @@ -187,23 +196,29 @@ public static function encode( /** * Sign a string with a given key and algorithm. * - * @param string $msg The message to sign - * @param string|OpenSSLAsymmetricKey $key The secret key. - * @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', - * 'HS512', 'RS256', 'RS384', and 'RS512' + * @param string $msg The message to sign + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key The secret key. + * @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', + * 'HS512', 'RS256', 'RS384', and 'RS512' * * @return string An encrypted message * * @throws DomainException Unsupported algorithm or bad key was specified */ - public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, string $alg): string - { + public static function sign( + string $msg, + string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key, + string $alg + ): string { if (empty(static::$supported_algs[$alg])) { throw new DomainException('Algorithm not supported'); } list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'hash_hmac': + if (!is_string($key)) { + throw new InvalidArgumentException('key must be a string when using hmac'); + } return \hash_hmac($algorithm, $msg, $key, true); case 'openssl': $signature = ''; @@ -221,10 +236,13 @@ public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, strin if (!function_exists('sodium_crypto_sign_detached')) { throw new DomainException('libsodium is not available'); } + 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(end($lines)); + $key = base64_decode((string) end($lines)); return sodium_crypto_sign_detached($msg, $key); } catch (Exception $e) { throw new DomainException($e->getMessage(), 0, $e); @@ -238,10 +256,10 @@ public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, strin * Verify a signature with the message, key and method. Not all methods * are symmetric, so we must have a separate verify and sign method. * - * @param string $msg The original message (header and body) - * @param string $signature The original signature - * @param string|OpenSSLAsymmetricKey $key For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey - * @param string $alg The algorithm + * @param string $msg The original message (header and body) + * @param string $signature The original signature + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey + * @param string $alg The algorithm * * @return bool * @@ -250,7 +268,7 @@ public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, strin private static function verify( string $msg, string $signature, - string|OpenSSLAsymmetricKey $keyMaterial, + string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial, string $alg ): bool { if (empty(static::$supported_algs[$alg])) { @@ -274,16 +292,22 @@ private static function verify( 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(end($lines)); + $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); } case 'hash_hmac': default: + if (!is_string($keyMaterial)) { + throw new InvalidArgumentException('key must be a string when using hmac'); + } $hash = \hash_hmac($algorithm, $msg, $keyMaterial, true); return self::constantTimeEquals($hash, $signature); } @@ -303,7 +327,7 @@ public static function jsonDecode(string $input): mixed $obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING); if ($errno = \json_last_error()) { - static::handleJsonError($errno); + self::handleJsonError($errno); } elseif ($obj === null && $input !== 'null') { throw new DomainException('Null result with non-null input'); } @@ -313,13 +337,13 @@ public static function jsonDecode(string $input): mixed /** * Encode a PHP array into a JSON string. * - * @param array $input A PHP array + * @param array $input A PHP array * - * @return string JSON representation of the PHP array + * @return string|false JSON representation of the PHP array * * @throws DomainException Provided object could not be encoded to valid JSON */ - public static function jsonEncode(array $input): string + public static function jsonEncode(array $input): string|false { if (PHP_VERSION_ID >= 50400) { $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); @@ -328,7 +352,7 @@ public static function jsonEncode(array $input): string $json = \json_encode($input); } if ($errno = \json_last_error()) { - static::handleJsonError($errno); + self::handleJsonError($errno); } elseif ($json === 'null' && $input !== null) { throw new DomainException('Null result with non-null input'); } @@ -342,7 +366,7 @@ public static function jsonEncode(array $input): string * * @return string A decoded string */ - public static function urlsafeB64Decode(string $input): string + public static function urlsafeB64Decode(string $input): string|false { $remainder = \strlen($input) % 4; if ($remainder) { @@ -381,29 +405,22 @@ private static function getKey(Key|array|ArrayAccess $keyOrKeyArray, ?string $ki return $keyOrKeyArray; } - if (is_array($keyOrKeyArray) || $keyOrKeyArray instanceof ArrayAccess) { - 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)) { - throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); - } - if (!isset($keyOrKeyArray[$kid])) { - throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); + 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' + ); } - - return $keyOrKeyArray[$kid]; + } + if (!isset($kid)) { + throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); + } + if (!isset($keyOrKeyArray[$kid])) { + throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); } - throw new UnexpectedValueException( - '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an ' - . 'array of Firebase\JWT\Key keys' - ); + return $keyOrKeyArray[$kid]; } /** @@ -416,13 +433,13 @@ public static function constantTimeEquals(string $left, string $right): bool if (\function_exists('hash_equals')) { return \hash_equals($left, $right); } - $len = \min(static::safeStrlen($left), static::safeStrlen($right)); + $len = \min(self::safeStrlen($left), self::safeStrlen($right)); $status = 0; for ($i = 0; $i < $len; $i++) { $status |= (\ord($left[$i]) ^ \ord($right[$i])); } - $status |= (static::safeStrlen($left) ^ static::safeStrlen($right)); + $status |= (self::safeStrlen($left) ^ self::safeStrlen($right)); return ($status === 0); } @@ -476,7 +493,8 @@ private static function safeStrlen(string $str): int private static function signatureToDER(string $sig): string { // Separate the signature into r-value and s-value - list($r, $s) = \str_split($sig, (int) (\strlen($sig) / 2)); + $length = max(1, (int) (\strlen($sig) / 2)); + list($r, $s) = \str_split($sig, $length > 0 ? $length : 1); // Trim leading zeros $r = \ltrim($r, "\x00"); @@ -556,7 +574,7 @@ private static function signatureFromDER(string $der, int $keySize): string * @param int $offset the offset of the data stream containing the object * to decode * - * @return array [$offset, $data] the new offset and the decoded object + * @return array{int, string|null} the new offset and the decoded object */ private static function readDER(string $der, int $offset = 0): array { diff --git a/src/Key.php b/src/Key.php index 286a3143..2f648dec 100644 --- a/src/Key.php +++ b/src/Key.php @@ -3,26 +3,20 @@ namespace Firebase\JWT; use OpenSSLAsymmetricKey; +use OpenSSLCertificate; use TypeError; use InvalidArgumentException; class Key { /** - * @param string|OpenSSLAsymmetricKey $keyMaterial + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial * @param string $algorithm */ public function __construct( - private string|OpenSSLAsymmetricKey $keyMaterial, + private string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial, private string $algorithm ) { - if ( - !is_string($keyMaterial) - && !$keyMaterial instanceof OpenSSLAsymmetricKey - ) { - throw new TypeError('Key material must be a string or OpenSSLAsymmetricKey'); - } - if (empty($keyMaterial)) { throw new InvalidArgumentException('Key material must not be empty'); } @@ -43,9 +37,9 @@ public function getAlgorithm(): string } /** - * @return string|OpenSSLAsymmetricKey + * @return string|OpenSSLAsymmetricKey|OpenSSLCertificate|array */ - public function getKeyMaterial(): mixed + public function getKeyMaterial(): string|OpenSSLAsymmetricKey|OpenSSLCertificate|array { return $this->keyMaterial; } From 52943f51c5caea1f6665f769f047a4e6e7fc6951 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Sun, 20 Mar 2022 15:59:35 -0700 Subject: [PATCH 033/121] feat: add back compatibility for >= PHP 7.1 (#405) --- .github/workflows/tests.yml | 2 +- composer.json | 4 +-- src/JWK.php | 22 +++++--------- src/JWT.php | 59 ++++++++++++++++++++----------------- src/Key.php | 28 ++++++++++++++---- 5 files changed, 66 insertions(+), 49 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 81630645..68d4f10b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ "8.0", "8.1"] + php: [ "7.1", "7.2", "7.3", "7.4", "8.0", "8.1"] name: PHP ${{matrix.php }} Unit Test steps: - uses: actions/checkout@v2 diff --git a/composer.json b/composer.json index 4e190ea3..5ef2ea2d 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ ], "license": "BSD-3-Clause", "require": { - "php": "^8.0" + "php": "^7.1||^8.0" }, "suggest": { "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" @@ -31,6 +31,6 @@ } }, "require-dev": { - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^7.5||9.5" } } diff --git a/src/JWK.php b/src/JWK.php index d5ad93a8..dbc446e6 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -128,24 +128,18 @@ private static function createPemFromModulusAndExponent( string $n, string $e ): string { - if (false === ($modulus = JWT::urlsafeB64Decode($n))) { - throw new UnexpectedValueException('Invalid JWK encoding'); - } - if (false === ($publicExponent = JWT::urlsafeB64Decode($e))) { - throw new UnexpectedValueException('Invalid header encoding'); - } + $mod = JWT::urlsafeB64Decode($n); + $exp = JWT::urlsafeB64Decode($e); - $components = [ - 'modulus' => \pack('Ca*a*', 2, self::encodeLength(\strlen($modulus)), $modulus), - 'publicExponent' => \pack('Ca*a*', 2, self::encodeLength(\strlen($publicExponent)), $publicExponent) - ]; + $modulus = \pack('Ca*a*', 2, self::encodeLength(\strlen($mod)), $mod); + $publicExponent = \pack('Ca*a*', 2, self::encodeLength(\strlen($exp)), $exp); $rsaPublicKey = \pack( 'Ca*a*a*', 48, - self::encodeLength(\strlen($components['modulus']) + \strlen($components['publicExponent'])), - $components['modulus'], - $components['publicExponent'] + self::encodeLength(\strlen($modulus) + \strlen($publicExponent)), + $modulus, + $publicExponent ); // sequence(oid(1.2.840.113549.1.1.1), null)) = rsaEncryption. @@ -176,7 +170,7 @@ private static function createPemFromModulusAndExponent( * @param int $length * @return string */ - private static function encodeLength($length) + private static function encodeLength(int $length): string { if ($length <= 0x7F) { return \chr($length); diff --git a/src/JWT.php b/src/JWT.php index cf58fd2a..f6a4772c 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -36,13 +36,15 @@ class JWT * When checking nbf, iat or expiration times, * we want to provide some extra leeway time to * account for clock skew. + * + * @var int */ - public static int $leeway = 0; + public static $leeway = 0; /** * @var array */ - public static array $supported_algs = [ + public static $supported_algs = [ 'ES384' => ['openssl', 'SHA384'], 'ES256' => ['openssl', 'SHA256'], 'HS256' => ['hash_hmac', 'SHA256'], @@ -77,8 +79,10 @@ class JWT * @uses jsonDecode * @uses urlsafeB64Decode */ - public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray): stdClass - { + public static function decode( + string $jwt, + $keyOrKeyArray + ): stdClass { // Validate JWT $timestamp = \time(); @@ -90,24 +94,18 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray) throw new UnexpectedValueException('Wrong number of segments'); } list($headb64, $bodyb64, $cryptob64) = $tks; - if (false === ($headerRaw = static::urlsafeB64Decode($headb64))) { - throw new UnexpectedValueException('Invalid header encoding'); - } + $headerRaw = static::urlsafeB64Decode($headb64); if (null === ($header = static::jsonDecode($headerRaw))) { throw new UnexpectedValueException('Invalid header encoding'); } - if (false === ($payloadRaw = static::urlsafeB64Decode($bodyb64))) { - throw new UnexpectedValueException('Invalid claims encoding'); - } + $payloadRaw = static::urlsafeB64Decode($bodyb64); if (null === ($payload = static::jsonDecode($payloadRaw))) { throw new UnexpectedValueException('Invalid claims encoding'); } if (!$payload instanceof stdClass) { throw new UnexpectedValueException('Payload must be a JSON object'); } - if (false === ($sig = static::urlsafeB64Decode($cryptob64))) { - throw new UnexpectedValueException('Invalid signature encoding'); - } + $sig = static::urlsafeB64Decode($cryptob64); if (empty($header->alg)) { throw new UnexpectedValueException('Empty algorithm'); } @@ -159,7 +157,7 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray) * Converts and signs a PHP object or array into a JWT string. * * @param array $payload PHP array - * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key The secret key. + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. * @param string $keyId * @param array $head An array with header elements to attach * @@ -170,7 +168,7 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray) */ public static function encode( array $payload, - string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key, + $key, string $alg, string $keyId = null, array $head = null @@ -197,7 +195,7 @@ public static function encode( * Sign a string with a given key and algorithm. * * @param string $msg The message to sign - * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key The secret key. + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. * @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', * 'HS512', 'RS256', 'RS384', and 'RS512' * @@ -207,7 +205,7 @@ public static function encode( */ public static function sign( string $msg, - string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key, + $key, string $alg ): string { if (empty(static::$supported_algs[$alg])) { @@ -222,7 +220,7 @@ public static function sign( return \hash_hmac($algorithm, $msg, $key, true); case 'openssl': $signature = ''; - $success = \openssl_sign($msg, $signature, $key, $algorithm); + $success = \openssl_sign($msg, $signature, $key, $algorithm); // @phpstan-ignore-line if (!$success) { throw new DomainException("OpenSSL unable to sign data"); } @@ -258,7 +256,7 @@ public static function sign( * * @param string $msg The original message (header and body) * @param string $signature The original signature - * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey * @param string $alg The algorithm * * @return bool @@ -268,7 +266,7 @@ public static function sign( private static function verify( string $msg, string $signature, - string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial, + $keyMaterial, string $alg ): bool { if (empty(static::$supported_algs[$alg])) { @@ -278,7 +276,7 @@ private static function verify( list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'openssl': - $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); + $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); // @phpstan-ignore-line if ($success === 1) { return true; } elseif ($success === 0) { @@ -322,7 +320,7 @@ private static function verify( * * @throws DomainException Provided string was invalid JSON */ - public static function jsonDecode(string $input): mixed + public static function jsonDecode(string $input) { $obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING); @@ -339,11 +337,11 @@ public static function jsonDecode(string $input): mixed * * @param array $input A PHP array * - * @return string|false JSON representation of the PHP array + * @return string JSON representation of the PHP array * * @throws DomainException Provided object could not be encoded to valid JSON */ - public static function jsonEncode(array $input): string|false + public static function jsonEncode(array $input): string { if (PHP_VERSION_ID >= 50400) { $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); @@ -356,6 +354,9 @@ public static function jsonEncode(array $input): string|false } elseif ($json === 'null' && $input !== null) { throw new DomainException('Null result with non-null input'); } + if ($json === false) { + throw new DomainException('Provided object could not be encoded to valid JSON'); + } return $json; } @@ -365,8 +366,10 @@ public static function jsonEncode(array $input): string|false * @param string $input A Base64 encoded string * * @return string A decoded string + * + * @throws InvalidArgumentException invalid base64 characters */ - public static function urlsafeB64Decode(string $input): string|false + public static function urlsafeB64Decode(string $input): string { $remainder = \strlen($input) % 4; if ($remainder) { @@ -399,8 +402,10 @@ public static function urlsafeB64Encode(string $input): string * * @return Key */ - private static function getKey(Key|array|ArrayAccess $keyOrKeyArray, ?string $kid): Key - { + private static function getKey( + $keyOrKeyArray, + ?string $kid + ): Key { if ($keyOrKeyArray instanceof Key) { return $keyOrKeyArray; } diff --git a/src/Key.php b/src/Key.php index 2f648dec..b09ad190 100644 --- a/src/Key.php +++ b/src/Key.php @@ -9,14 +9,28 @@ class Key { + /** @var string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate */ + private $keyMaterial; + /** @var string */ + private $algorithm; + /** - * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial * @param string $algorithm */ public function __construct( - private string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial, - private string $algorithm + $keyMaterial, + string $algorithm ) { + if ( + !is_string($keyMaterial) + && !$keyMaterial instanceof OpenSSLAsymmetricKey + && !$keyMaterial instanceof OpenSSLCertificate + && !is_resource($keyMaterial) + ) { + throw new TypeError('Key material must be a string, resource, or OpenSSLAsymmetricKey'); + } + if (empty($keyMaterial)) { throw new InvalidArgumentException('Key material must not be empty'); } @@ -24,6 +38,10 @@ public function __construct( if (empty($algorithm)) { throw new InvalidArgumentException('Algorithm must not be empty'); } + + // TODO: Remove in PHP 8.0 in favor of class constructor property promotion + $this->keyMaterial = $keyMaterial; + $this->algorithm = $algorithm; } /** @@ -37,9 +55,9 @@ public function getAlgorithm(): string } /** - * @return string|OpenSSLAsymmetricKey|OpenSSLCertificate|array + * @return string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate */ - public function getKeyMaterial(): string|OpenSSLAsymmetricKey|OpenSSLCertificate|array + public function getKeyMaterial() { return $this->keyMaterial; } From 5bbc90c14db454eea88e1ffff9ac727c8136392a Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 23 Mar 2022 11:12:10 -0700 Subject: [PATCH 034/121] chore: add back timestamp var for compatibility (#412) --- src/JWT.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index f6a4772c..843d0ae0 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -41,6 +41,15 @@ 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 */ @@ -84,7 +93,7 @@ public static function decode( $keyOrKeyArray ): stdClass { // Validate JWT - $timestamp = \time(); + $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; if (empty($keyOrKeyArray)) { throw new InvalidArgumentException('Key may not be empty'); From fbb2967a3a68b07e37678c00c0cf51165051495f Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 23 Mar 2022 11:26:04 -0700 Subject: [PATCH 035/121] chore: update CHANGELOG for v6.1.0 --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index d489c69f..2dd7669b 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,11 @@ $decoded = json_decode(json_encode($decoded), true); Changelog --------- +#### 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. From a57a898bf7e6d3b64d8b7890d6a89bd2118c1d64 Mon Sep 17 00:00:00 2001 From: Andres Berdugo Date: Wed, 13 Apr 2022 16:32:21 -0500 Subject: [PATCH 036/121] chore: add missing docblock param for $alg (#419) --- src/JWT.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/JWT.php b/src/JWT.php index 843d0ae0..bf064e34 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -167,6 +167,8 @@ public static function decode( * * @param array $payload PHP array * @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 $keyId * @param array $head An array with header elements to attach * From e67638d067a537731e3f9c03e097c7c1b1f31fe8 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 13 Apr 2022 18:27:14 -0500 Subject: [PATCH 037/121] fix: add flag to force object (#416) --- src/JWT.php | 2 +- tests/JWTTest.php | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index bf064e34..67514b29 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -355,7 +355,7 @@ 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); + $json = \json_encode($input, \JSON_UNESCAPED_SLASHES|\JSON_FORCE_OBJECT); } else { // PHP 5.3 only $json = \json_encode($input); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 8b23ad6b..e1984b34 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -321,6 +321,15 @@ 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); + } + /** * @runInSeparateProcess * @dataProvider provideEncodeDecode From c297139da7c6873dbd67cbd1093f09ec0bbd0c50 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 21 Apr 2022 08:37:18 -0600 Subject: [PATCH 038/121] fix: revert add flag to force object (#420) --- src/JWT.php | 6 +++++- tests/JWTTest.php | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index 67514b29..57d92ba8 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -111,6 +111,10 @@ public static function decode( 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'); } @@ -355,7 +359,7 @@ 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|\JSON_FORCE_OBJECT); + $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); } else { // PHP 5.3 only $json = \json_encode($input); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index e1984b34..191e3d2c 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -330,6 +330,15 @@ public function testDecodesEmptyArrayAsObject() $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->assertEquals($payload['foo'], $decoded->foo); + } + /** * @runInSeparateProcess * @dataProvider provideEncodeDecode From 0ac50413aa4c045dbbcf94ff157a912352de4a8e Mon Sep 17 00:00:00 2001 From: Martin Krisell Date: Sat, 23 Apr 2022 19:36:00 +0200 Subject: [PATCH 039/121] chore: styles for short array and single quotes (#423) --- README.md | 50 +++++++++++++++++++++++------------------------ src/JWT.php | 2 +- tests/JWKTest.php | 4 ++-- tests/JWTTest.php | 44 ++++++++++++++++++++--------------------- 4 files changed, 50 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 2dd7669b..65e8fc18 100644 --- a/README.md +++ b/README.md @@ -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: @@ -98,12 +98,12 @@ ehde/zUxo6UvS7UrBQIDAQAB -----END PUBLIC KEY----- EOD; -$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"; @@ -139,12 +139,12 @@ $privateKey = openssl_pkey_get_private( $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 +173,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"; diff --git a/src/JWT.php b/src/JWT.php index 57d92ba8..7db16c4b 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -237,7 +237,7 @@ public static function sign( $signature = ''; $success = \openssl_sign($msg, $signature, $key, $algorithm); // @phpstan-ignore-line if (!$success) { - throw new DomainException("OpenSSL unable to sign data"); + throw new DomainException('OpenSSL unable to sign data'); } if ($alg === 'ES256') { $signature = self::signatureFromDER($signature, 256); diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 17dd4a62..29d2356a 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -125,7 +125,7 @@ public function testDecodeByJwkKeySet() $result = JWT::decode($msg, self::$keys); - $this->assertEquals("foo", $result->sub); + $this->assertEquals('foo', $result->sub); } /** @@ -139,6 +139,6 @@ public function testDecodeByMultiJwkKeySet() $result = JWT::decode($msg, self::$keys); - $this->assertEquals("bar", $result->sub); + $this->assertEquals('bar', $result->sub); } } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 191e3d2c..98562ca1 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -36,8 +36,8 @@ public function testExpiredToken() { $this->expectException(ExpiredException::class); $payload = [ - "message" => "abc", - "exp" => time() - 20]; // time in the past + 'message' => 'abc', + 'exp' => time() - 20]; // time in the past $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); } @@ -46,7 +46,7 @@ public function testBeforeValidTokenWithNbf() { $this->expectException(BeforeValidException::class); $payload = [ - "message" => "abc", + 'message' => 'abc', "nbf" => time() + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -56,7 +56,7 @@ public function testBeforeValidTokenWithIat() { $this->expectException(BeforeValidException::class); $payload = [ - "message" => "abc", + 'message' => 'abc', "iat" => time() + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -65,8 +65,8 @@ public function testBeforeValidTokenWithIat() public function testValidToken() { $payload = [ - "message" => "abc", - "exp" => time() + JWT::$leeway + 20]; // time in the future + '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'); @@ -76,8 +76,8 @@ public function testValidTokenWithLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", - "exp" => time() - 20]; // time in the past + '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'); @@ -88,8 +88,8 @@ public function testExpiredTokenWithLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", - "exp" => time() - 70]; // time far in the past + 'message' => 'abc', + 'exp' => time() - 70]; // time far in the past $this->expectException(ExpiredException::class); $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -100,9 +100,9 @@ public function testExpiredTokenWithLeeway() public function testValidTokenWithNbf() { $payload = [ - "message" => "abc", + 'message' => 'abc', "iat" => time(), - "exp" => time() + 20, // time in the future + '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')); @@ -113,7 +113,7 @@ public function testValidTokenWithNbfLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", + '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')); @@ -125,7 +125,7 @@ public function testInvalidTokenWithNbfLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", + 'message' => 'abc', "nbf" => time() + 65]; // not before too far in future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(BeforeValidException::class); @@ -137,7 +137,7 @@ public function testValidTokenWithIatLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", + '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')); @@ -149,7 +149,7 @@ public function testInvalidTokenWithIatLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", + 'message' => 'abc', "iat" => time() + 65]; // issued too far in future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(BeforeValidException::class); @@ -160,8 +160,8 @@ public function testInvalidTokenWithIatLeeway() public function testInvalidToken() { $payload = [ - "message" => "abc", - "exp" => time() + 20]; // time in the future + 'message' => 'abc', + 'exp' => time() + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(SignatureInvalidException::class); JWT::decode($encoded, new Key('my_key2', 'HS256')); @@ -170,8 +170,8 @@ public function testInvalidToken() public function testNullKeyFails() { $payload = [ - "message" => "abc", - "exp" => time() + JWT::$leeway + 20]; // time in the future + 'message' => 'abc', + 'exp' => time() + JWT::$leeway + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(TypeError::class); JWT::decode($encoded, new Key(null, 'HS256')); @@ -180,8 +180,8 @@ public function testNullKeyFails() public function testEmptyKeyFails() { $payload = [ - "message" => "abc", - "exp" => time() + JWT::$leeway + 20]; // time in the future + 'message' => 'abc', + 'exp' => time() + JWT::$leeway + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(InvalidArgumentException::class); JWT::decode($encoded, new Key('', 'HS256')); From 6a6025beec4ad9fbb0b47b5d5ac144810fced240 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 26 Apr 2022 10:52:40 -0600 Subject: [PATCH 040/121] chore: add cs config, fix git-attributes, removed unused entrypoint.sh (#424) --- .gitattributes | 7 +++-- .github/actions/entrypoint.sh | 21 -------------- .github/workflows/tests.yml | 5 ++-- .gitignore | 1 + .php-cs-fixer.dist.php | 24 ++++++++++++++++ src/JWT.php | 19 ++++++------- src/Key.php | 6 ++-- tests/JWKTest.php | 6 ++-- tests/JWTTest.php | 52 ++++++++++++++++++++++------------- 9 files changed, 79 insertions(+), 62 deletions(-) delete mode 100755 .github/actions/entrypoint.sh create mode 100644 .php-cs-fixer.dist.php 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/tests.yml b/.github/workflows/tests.yml index 68d4f10b..fd10d13b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,9 +38,8 @@ jobs: php-version: "8.0" - 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 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..fb636632 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,24 @@ +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 + ], + ]) + ->setFinder( + PhpCsFixer\Finder::create() + ->in(__DIR__) + ) +; diff --git a/src/JWT.php b/src/JWT.php index 7db16c4b..98a503e4 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -2,16 +2,15 @@ namespace Firebase\JWT; -use ArrayAccess; +use DateTime; use DomainException; use Exception; use InvalidArgumentException; use OpenSSLAsymmetricKey; use OpenSSLCertificate; +use stdClass; use TypeError; use UnexpectedValueException; -use DateTime; -use stdClass; /** * JSON Web Token implementation, based on this spec: @@ -111,7 +110,7 @@ public static function decode( if (null === ($payload = static::jsonDecode($payloadRaw))) { throw new UnexpectedValueException('Invalid claims encoding'); } - if (is_array($payload)) { + if (\is_array($payload)) { // prevent PHP Fatal Error in edge-cases when payload is empty array $payload = (object) $payload; } @@ -229,7 +228,7 @@ 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'); } return \hash_hmac($algorithm, $msg, $key, true); @@ -246,10 +245,10 @@ public static function sign( } 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 { @@ -302,10 +301,10 @@ private static function verify( 'OpenSSL error: ' . \openssl_error_string() ); case 'sodium_crypto': - if (!function_exists('sodium_crypto_sign_verify_detached')) { + if (!\function_exists('sodium_crypto_sign_verify_detached')) { throw new DomainException('libsodium is not available'); } - if (!is_string($keyMaterial)) { + if (!\is_string($keyMaterial)) { throw new InvalidArgumentException('key must be a string when using EdDSA'); } try { @@ -318,7 +317,7 @@ private static function verify( } case 'hash_hmac': default: - if (!is_string($keyMaterial)) { + if (!\is_string($keyMaterial)) { throw new InvalidArgumentException('key must be a string when using hmac'); } $hash = \hash_hmac($algorithm, $msg, $keyMaterial, true); diff --git a/src/Key.php b/src/Key.php index b09ad190..00cf7f2e 100644 --- a/src/Key.php +++ b/src/Key.php @@ -2,10 +2,10 @@ namespace Firebase\JWT; +use InvalidArgumentException; use OpenSSLAsymmetricKey; use OpenSSLCertificate; use TypeError; -use InvalidArgumentException; class Key { @@ -23,10 +23,10 @@ public function __construct( string $algorithm ) { if ( - !is_string($keyMaterial) + !\is_string($keyMaterial) && !$keyMaterial instanceof OpenSSLAsymmetricKey && !$keyMaterial instanceof OpenSSLCertificate - && !is_resource($keyMaterial) + && !\is_resource($keyMaterial) ) { throw new TypeError('Key material must be a string, resource, or OpenSSLAsymmetricKey'); } diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 29d2356a..4167a2ba 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 @@ -69,7 +69,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 +79,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; } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 98562ca1..58c334ed 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -3,12 +3,12 @@ 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 { @@ -37,7 +37,8 @@ public function testExpiredToken() $this->expectException(ExpiredException::class); $payload = [ 'message' => 'abc', - 'exp' => time() - 20]; // time in the past + 'exp' => time() - 20, // time in the past + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); } @@ -47,7 +48,8 @@ public function testBeforeValidTokenWithNbf() $this->expectException(BeforeValidException::class); $payload = [ 'message' => 'abc', - "nbf" => time() + 20]; // time in the future + 'nbf' => time() + 20, // time in the future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); } @@ -57,7 +59,8 @@ public function testBeforeValidTokenWithIat() $this->expectException(BeforeValidException::class); $payload = [ 'message' => 'abc', - "iat" => time() + 20]; // time in the future + 'iat' => time() + 20, // time in the future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); } @@ -66,7 +69,8 @@ public function testValidToken() { $payload = [ 'message' => 'abc', - 'exp' => time() + JWT::$leeway + 20]; // time in the future + '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'); @@ -77,7 +81,8 @@ public function testValidTokenWithLeeway() JWT::$leeway = 60; $payload = [ 'message' => 'abc', - 'exp' => time() - 20]; // time in the past + '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'); @@ -89,7 +94,8 @@ public function testExpiredTokenWithLeeway() JWT::$leeway = 60; $payload = [ 'message' => 'abc', - 'exp' => time() - 70]; // time far in the past + 'exp' => time() - 70, // time far in the past + ]; $this->expectException(ExpiredException::class); $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -101,9 +107,10 @@ public function testValidTokenWithNbf() { $payload = [ 'message' => 'abc', - "iat" => time(), + 'iat' => time(), 'exp' => time() + 20, // time in the future - "nbf" => time() - 20]; + 'nbf' => time() - 20 + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -114,7 +121,8 @@ public function testValidTokenWithNbfLeeway() JWT::$leeway = 60; $payload = [ 'message' => 'abc', - "nbf" => time() + 20]; // not before in near (leeway) future + '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'); @@ -126,7 +134,8 @@ public function testInvalidTokenWithNbfLeeway() JWT::$leeway = 60; $payload = [ 'message' => 'abc', - "nbf" => time() + 65]; // not before too far in future + 'nbf' => time() + 65, // not before too far in future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(BeforeValidException::class); JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -138,7 +147,8 @@ public function testValidTokenWithIatLeeway() JWT::$leeway = 60; $payload = [ 'message' => 'abc', - "iat" => time() + 20]; // issued in near (leeway) future + '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'); @@ -150,7 +160,8 @@ public function testInvalidTokenWithIatLeeway() JWT::$leeway = 60; $payload = [ 'message' => 'abc', - "iat" => time() + 65]; // issued too far in future + 'iat' => time() + 65, // issued too far in future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(BeforeValidException::class); JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -161,7 +172,8 @@ public function testInvalidToken() { $payload = [ 'message' => 'abc', - 'exp' => time() + 20]; // time in the future + 'exp' => time() + 20, // time in the future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(SignatureInvalidException::class); JWT::decode($encoded, new Key('my_key2', 'HS256')); @@ -171,7 +183,8 @@ public function testNullKeyFails() { $payload = [ 'message' => 'abc', - 'exp' => time() + JWT::$leeway + 20]; // time in the future + 'exp' => time() + JWT::$leeway + 20, // time in the future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(TypeError::class); JWT::decode($encoded, new Key(null, 'HS256')); @@ -181,7 +194,8 @@ public function testEmptyKeyFails() { $payload = [ 'message' => 'abc', - 'exp' => time() + JWT::$leeway + 20]; // time in the future + 'exp' => time() + JWT::$leeway + 20, // time in the future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(InvalidArgumentException::class); JWT::decode($encoded, new Key('', 'HS256')); @@ -250,7 +264,7 @@ public function testInvalidSegmentCount() 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')); } @@ -333,7 +347,7 @@ public function testDecodesEmptyArrayAsObject() public function testDecodesArraysInJWTAsArray() { $key = 'yma6Hq4XQegCVND8ef23OYgxSrC3IKqk'; - $payload = ['foo' => [1,2,3]]; + $payload = ['foo' => [1, 2, 3]]; $jwt = JWT::encode($payload, $key, 'HS256'); $decoded = JWT::decode($jwt, new Key($key, 'HS256')); $this->assertEquals($payload['foo'], $decoded->foo); From 2308363dfd10eef9491a0c8adf4437dba94717c5 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 27 Apr 2022 16:26:15 -0600 Subject: [PATCH 041/121] feat: add cached keyset (#397) --- .github/workflows/tests.yml | 1 + README.md | 39 +++ composer.json | 7 +- phpunit.xml.dist | 2 +- src/CachedKeySet.php | 225 +++++++++++++++++ src/JWT.php | 19 +- tests/CachedKeySetTest.php | 481 ++++++++++++++++++++++++++++++++++++ tests/bootstrap.php | 14 -- 8 files changed, 761 insertions(+), 27 deletions(-) create mode 100644 src/CachedKeySet.php create mode 100644 tests/CachedKeySetTest.php delete mode 100644 tests/bootstrap.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fd10d13b..83dc88da 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -52,5 +52,6 @@ jobs: php-version: '8.0' - name: Run Script run: | + composer install composer global require phpstan/phpstan ~/.composer/vendor/bin/phpstan analyse diff --git a/README.md b/README.md index 65e8fc18..42e8b6db 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,45 @@ $jwks = ['keys' => []]; JWT::decode($payload, 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 ------------- diff --git a/composer.json b/composer.json index 5ef2ea2d..2a3cb2df 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,11 @@ } }, "require-dev": { - "phpunit/phpunit": "^7.5||9.5" + "guzzlehttp/guzzle": "^6.5||^7.4", + "phpspec/prophecy-phpunit": "^1.1", + "phpunit/phpunit": "^7.5||^9.5", + "psr/cache": "^1.0||^2.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/CachedKeySet.php b/src/CachedKeySet.php new file mode 100644 index 00000000..077dceb0 --- /dev/null +++ b/src/CachedKeySet.php @@ -0,0 +1,225 @@ + + */ +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; + + public function __construct( + string $jwksUri, + ClientInterface $httpClient, + RequestFactoryInterface $httpFactory, + CacheItemPoolInterface $cache, + int $expiresAfter = null, + bool $rateLimit = false + ) { + $this->jwksUri = $jwksUri; + $this->httpClient = $httpClient; + $this->httpFactory = $httpFactory; + $this->cache = $cache; + $this->expiresAfter = $expiresAfter; + $this->rateLimit = $rateLimit; + $this->setCacheKeys(); + } + + /** + * @param string $keyId + * @return Key + */ + public function offsetGet($keyId): Key + { + if (!$this->keyIdExists($keyId)) { + throw new OutOfBoundsException('Key ID not found'); + } + return $this->keySet[$keyId]; + } + + /** + * @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'); + } + + private function keyIdExists(string $keyId): bool + { + $keySetToCache = null; + if (null === $this->keySet) { + $item = $this->getCacheItem(); + // Try to load keys from cache + if ($item->isHit()) { + // item found! Return it + $this->keySet = $item->get(); + } + } + + if (!isset($this->keySet[$keyId])) { + if ($this->rateLimitExceeded()) { + return false; + } + $request = $this->httpFactory->createRequest('get', $this->jwksUri); + $jwksResponse = $this->httpClient->sendRequest($request); + $jwks = json_decode((string) $jwksResponse->getBody(), true); + $this->keySet = $keySetToCache = JWK::parseKeySet($jwks); + + if (!isset($this->keySet[$keyId])) { + return false; + } + } + + if ($keySetToCache) { + $item = $this->getCacheItem(); + $item->set($keySetToCache); + 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); + if (!$cacheItem->isHit()) { + $cacheItem->expiresAfter(1); // # of calls are cached each minute + } + + $callsPerMinute = (int) $cacheItem->get(); + if (++$callsPerMinute > $this->maxCallsPerMinute) { + return true; + } + $cacheItem->set($callsPerMinute); + $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/JWT.php b/src/JWT.php index 98a503e4..9011292f 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -2,6 +2,7 @@ namespace Firebase\JWT; +use ArrayAccess; use DateTime; use DomainException; use Exception; @@ -9,7 +10,6 @@ use OpenSSLAsymmetricKey; use OpenSSLCertificate; use stdClass; -use TypeError; use UnexpectedValueException; /** @@ -68,7 +68,7 @@ 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. + * @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', @@ -409,7 +409,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 @@ -424,15 +424,12 @@ private static function getKey( 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 ($keyOrKeyArray instanceof CachedKeySet) { + // Skip "isset" check, as this will automatically refresh if not set + return $keyOrKeyArray[$kid]; } - if (!isset($kid)) { + + if (empty($kid)) { throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); } if (!isset($keyOrKeyArray[$kid])) { diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php new file mode 100644 index 00000000..22e1de5f --- /dev/null +++ b/tests/CachedKeySetTest.php @@ -0,0 +1,481 @@ +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 testWithExistingKeyId() + { + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks1), + $this->getMockHttpFactory(), + $this->getMockEmptyCache() + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); + $this->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + } + + public function testKeyIdIsCached() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->willReturn(true); + $cacheItem->get() + ->willReturn(JWK::parseKeySet(json_decode($this->testJwks1, true))); + + $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->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + } + + public function testCachedKeyIdRefresh() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->shouldBeCalledOnce() + ->willReturn(true); + $cacheItem->get() + ->shouldBeCalledOnce() + ->willReturn(JWK::parseKeySet(json_decode($this->testJwks1, true))); + $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->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + + $this->assertInstanceOf(Key::class, $cachedKeySet['bar']); + $this->assertEquals('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->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + } + + public function testJwtVerify() + { + $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); + $payload = array('sub' => 'foo', 'exp' => strtotime('+10 seconds')); + $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); + + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->willReturn(true); + $cacheItem->get() + ->willReturn(JWK::parseKeySet( + json_decode(file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), true) + )); + + $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->assertEquals('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), + $factory = $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])); + } + + /** + * @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->assertEquals($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()); + + $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 + { + return current($this->getItems([$key])); + } + + 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() + { + 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($value) + { + $this->isHit = true; + $this->value = $value; + + return $this; + } + + public function expiresAt($expiration) + { + $this->expiration = $expiration; + return $this; + } + + public function expiresAfter($time) + { + $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/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index 385b6706..00000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,14 +0,0 @@ - Date: Fri, 13 May 2022 13:54:50 -0700 Subject: [PATCH 042/121] feat: add defaultAlg param (#426) --- src/CachedKeySet.php | 10 ++++++++-- src/JWK.php | 22 +++++++++++++++------- tests/CachedKeySetTest.php | 16 ++++++++++++++++ tests/JWKTest.php | 12 ++++++++++++ 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index 077dceb0..f1580c92 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -68,6 +68,10 @@ class CachedKeySet implements ArrayAccess * @var int */ private $maxCallsPerMinute = 10; + /** + * @var string|null + */ + private $defaultAlg; public function __construct( string $jwksUri, @@ -75,7 +79,8 @@ public function __construct( RequestFactoryInterface $httpFactory, CacheItemPoolInterface $cache, int $expiresAfter = null, - bool $rateLimit = false + bool $rateLimit = false, + string $defaultAlg = null ) { $this->jwksUri = $jwksUri; $this->httpClient = $httpClient; @@ -83,6 +88,7 @@ public function __construct( $this->cache = $cache; $this->expiresAfter = $expiresAfter; $this->rateLimit = $rateLimit; + $this->defaultAlg = $defaultAlg; $this->setCacheKeys(); } @@ -143,7 +149,7 @@ private function keyIdExists(string $keyId): bool $request = $this->httpFactory->createRequest('get', $this->jwksUri); $jwksResponse = $this->httpClient->sendRequest($request); $jwks = json_decode((string) $jwksResponse->getBody(), true); - $this->keySet = $keySetToCache = JWK::parseKeySet($jwks); + $this->keySet = $keySetToCache = JWK::parseKeySet($jwks, $this->defaultAlg); if (!isset($this->keySet[$keyId])) { return false; diff --git a/src/JWK.php b/src/JWK.php index dbc446e6..7f225701 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -24,6 +24,8 @@ class JWK * 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 +35,7 @@ class JWK * * @uses parseKey */ - public static function parseKeySet(array $jwks): array + public static function parseKeySet(array $jwks, string $defaultAlg = null): array { $keys = []; @@ -47,7 +49,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 +65,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 +76,7 @@ public static function parseKeySet(array $jwks): array * * @uses createPemFromModulusAndExponent */ - public static function parseKey(array $jwk): ?Key + public static function parseKey(array $jwk, string $defaultAlg = null): ?Key { if (empty($jwk)) { throw new InvalidArgumentException('JWK must not be empty'); @@ -83,10 +87,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']) { diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 22e1de5f..a13c80fd 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -18,6 +18,7 @@ class CachedKeySetTest extends TestCase private $testJwksUriKey = 'jwkshttpsjwk.uri'; private $testJwks1 = '{"keys": [{"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'; @@ -95,6 +96,21 @@ public function testWithExistingKeyId() $this->assertEquals('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->assertEquals('baz256', $cachedKeySet['baz']->getAlgorithm()); + } + public function testKeyIdIsCached() { $cacheItem = $this->prophesize(CacheItemInterface::class); diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 4167a2ba..0bd4f636 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -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->assertEquals('foo', $jwks['jwk1']->getAlgorithm()); + } + public function testParseKeyWithEmptyDValue() { $jwkSet = json_decode( From 64ed2e50c733f914014a2c1e7b5e6d2e3bb4e979 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 14 Jul 2022 16:17:28 -0600 Subject: [PATCH 043/121] feat: Add ES256 support to JWK (#399) --- src/JWK.php | 133 ++++++++++++++++++++++++++++++++ tests/JWKTest.php | 24 ++++-- tests/data/ec-jwkset.json | 22 ++++++ tests/data/ecdsa256-private.pem | 4 + 4 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 tests/data/ec-jwkset.json create mode 100644 tests/data/ecdsa256-private.pem diff --git a/src/JWK.php b/src/JWK.php index 7f225701..c90de4e1 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -20,6 +20,16 @@ */ 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 + // 'P-384' => '1.3.132.0.34', // Len: 96 (not yet supported) + // 'P-521' => '1.3.132.0.35', // Len: 132 (not supported) + ]; + /** * Parse a set of JWK keys * @@ -114,6 +124,26 @@ public static function parseKey(array $jwk, string $defaultAlg = null): ?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']); default: // Currently only RSA is supported break; @@ -122,6 +152,45 @@ public static function parseKey(array $jwk, string $defaultAlg = null): ?Key return null; } + /** + * Converts the EC JWK values to pem format. + * + * @param string $crv The EC curve (only P-256 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 * @@ -188,4 +257,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/tests/JWKTest.php b/tests/JWKTest.php index 0bd4f636..b8c24f98 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -127,19 +127,33 @@ public function testDecodeByJwkKeySetTokenExpired() } /** - * @depends testParseJwkKeySet + * @dataProvider provideDecodeByJwkKeySet */ - public function testDecodeByJwkKeySet() + public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg) { - $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, 'jwk1'); - $result = JWT::decode($msg, self::$keys); + $jwkSet = json_decode( + file_get_contents(__DIR__ . '/data/' . $jwkFile), + true + ); + + $keys = JWK::parseKeySet($jwkSet); + $result = JWT::decode($msg, $keys); $this->assertEquals('foo', $result->sub); } + public function provideDecodeByJwkKeySet() + { + return [ + ['rsa1-private.pem', 'rsa-jwkset.json', 'RS256'], + ['ecdsa256-private.pem', 'ec-jwkset.json', 'ES256'], + ]; + } + /** * @depends testParseJwkKeySet */ diff --git a/tests/data/ec-jwkset.json b/tests/data/ec-jwkset.json new file mode 100644 index 00000000..46ed8cf9 --- /dev/null +++ b/tests/data/ec-jwkset.json @@ -0,0 +1,22 @@ +{ + "keys": [ + { + "kty": "EC", + "use": "sig", + "crv": "P-256", + "kid": "jwk1", + "x": "ALXnvdCvbBx35J2bozBkIFHPT747KiYioLK4JquMhZU", + "y": "fAt_rGPqS95Ytwdluh4TNWTmj9xkcAbKGBRpP5kuGBk", + "alg": "ES256" + }, + { + "kty": "EC", + "use": "sig", + "crv": "P-256", + "kid": "jwk2", + "x": "mQa0q5FvxPRujxzFazQT1Mo2YJJzuKiXU3svOJ41jhw", + "y": "jAz7UwIl2oOFk06kj42ZFMOXmGMFUGjKASvyYtibCH0", + "alg": "ES256" + } + ] +} \ No newline at end of file diff --git a/tests/data/ecdsa256-private.pem b/tests/data/ecdsa256-private.pem new file mode 100644 index 00000000..02b8f1b8 --- /dev/null +++ b/tests/data/ecdsa256-private.pem @@ -0,0 +1,4 @@ +-----BEGIN PRIVATE KEY----- +MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCD0KvVxLJEzRBQmcEXf +D2okKCNoUwZY8fc1/1Z4aJuJdg== +-----END PRIVATE KEY----- \ No newline at end of file From c68f2a7ad00b58f39b9713fbbae36246c7856200 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 14 Jul 2022 16:49:39 -0600 Subject: [PATCH 044/121] chore: fix styles and phpstan (#440) --- src/JWK.php | 10 +++++----- src/JWT.php | 30 +++++++++++++++--------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/JWK.php b/src/JWK.php index c90de4e1..a5dbc765 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -179,7 +179,7 @@ private static function createPemFromCrvAndXYCoordinates(string $crv, string $x, ) . self::encodeDER( self::ASN1_BIT_STRING, - chr(0x00) . chr(0x04) + \chr(0x00) . \chr(0x04) . JWT::urlsafeB64Decode($x) . JWT::urlsafeB64Decode($y) ) @@ -295,21 +295,21 @@ private static function encodeOID(string $oid): string // Get the first octet $first = (int) array_shift($octets); $second = (int) array_shift($octets); - $oid = chr($first * 40 + $second); + $oid = \chr($first * 40 + $second); // Iterate over subsequent octets foreach ($octets as $octet) { if ($octet == 0) { - $oid .= chr(0x00); + $oid .= \chr(0x00); continue; } $bin = ''; while ($octet) { - $bin .= chr(0x80 | ($octet & 0x7f)); + $bin .= \chr(0x80 | ($octet & 0x7f)); $octet >>= 7; } - $bin[0] = $bin[0] & chr(0x7f); + $bin[0] = $bin[0] & \chr(0x7f); // Convert to big endian if necessary if (pack('V', 65534) == pack('L', 65534)) { diff --git a/src/JWT.php b/src/JWT.php index 9011292f..084c4a45 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -301,20 +301,20 @@ 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)); + 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)) { @@ -510,7 +510,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"); From 607dcd47b0e68d7367823f50df4f38fb24d71a04 Mon Sep 17 00:00:00 2001 From: Brando Meniconi Date: Fri, 15 Jul 2022 18:09:18 +0200 Subject: [PATCH 045/121] fix: cache jwks as string in CachedKeySet (#435) --- src/CachedKeySet.php | 12 +++++------- tests/CachedKeySetTest.php | 9 ++++----- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index f1580c92..e2215b30 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -132,13 +132,13 @@ public function offsetUnset($offset): void private function keyIdExists(string $keyId): bool { - $keySetToCache = null; if (null === $this->keySet) { $item = $this->getCacheItem(); // Try to load keys from cache if ($item->isHit()) { // item found! Return it - $this->keySet = $item->get(); + $jwks = $item->get(); + $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg); } } @@ -148,17 +148,15 @@ private function keyIdExists(string $keyId): bool } $request = $this->httpFactory->createRequest('get', $this->jwksUri); $jwksResponse = $this->httpClient->sendRequest($request); - $jwks = json_decode((string) $jwksResponse->getBody(), true); - $this->keySet = $keySetToCache = JWK::parseKeySet($jwks, $this->defaultAlg); + $jwks = (string) $jwksResponse->getBody(); + $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg); if (!isset($this->keySet[$keyId])) { return false; } - } - if ($keySetToCache) { $item = $this->getCacheItem(); - $item->set($keySetToCache); + $item->set($jwks); if ($this->expiresAfter) { $item->expiresAfter($this->expiresAfter); } diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index a13c80fd..9e884e6b 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -117,7 +117,7 @@ public function testKeyIdIsCached() $cacheItem->isHit() ->willReturn(true); $cacheItem->get() - ->willReturn(JWK::parseKeySet(json_decode($this->testJwks1, true))); + ->willReturn($this->testJwks1); $cache = $this->prophesize(CacheItemPoolInterface::class); $cache->getItem($this->testJwksUriKey) @@ -143,7 +143,7 @@ public function testCachedKeyIdRefresh() ->willReturn(true); $cacheItem->get() ->shouldBeCalledOnce() - ->willReturn(JWK::parseKeySet(json_decode($this->testJwks1, true))); + ->willReturn($this->testJwks1); $cacheItem->set(Argument::any()) ->shouldBeCalledOnce() ->will(function () { @@ -217,9 +217,8 @@ public function testJwtVerify() $cacheItem->isHit() ->willReturn(true); $cacheItem->get() - ->willReturn(JWK::parseKeySet( - json_decode(file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), true) - )); + ->willReturn(file_get_contents(__DIR__ . '/data/rsa-jwkset.json') + ); $cache = $this->prophesize(CacheItemPoolInterface::class); $cache->getItem($this->testJwksUriKey) From 1494082c0cbd9bb342b64681af0275265dad47ed Mon Sep 17 00:00:00 2001 From: mehdihasanpour <47119405+mehdihasanpour@users.noreply.github.com> Date: Fri, 15 Jul 2022 20:58:30 +0430 Subject: [PATCH 046/121] chore: misc cleanup (#439) --- src/JWK.php | 4 +--- src/JWT.php | 9 +++++---- tests/CachedKeySetTest.php | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/JWK.php b/src/JWK.php index a5dbc765..15631ecc 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -231,11 +231,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; } /** diff --git a/src/JWT.php b/src/JWT.php index 084c4a45..9964073d 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -98,7 +98,7 @@ public static function decode( 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; @@ -136,7 +136,7 @@ public static function decode( // OpenSSL expects an ASN.1 DER sequence for ES256/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'); } @@ -293,7 +293,8 @@ private static function verify( $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); // @phpstan-ignore-line if ($success === 1) { return true; - } elseif ($success === 0) { + } + if ($success === 0) { return false; } // returns 1 on success, 0 on failure, -1 on error. @@ -610,7 +611,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; diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 9e884e6b..1dc2fdc0 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -210,7 +210,7 @@ public function testCacheItemWithExpiresAfter() public function testJwtVerify() { $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); - $payload = array('sub' => 'foo', 'exp' => strtotime('+10 seconds')); + $payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')]; $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); $cacheItem = $this->prophesize(CacheItemInterface::class); From 608668efd4c87335da32fb2e8a2e680163342273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ShaoBo=20Wan=28=E7=84=A1=E5=B0=98=29?= <756684177@qq.com> Date: Sat, 16 Jul 2022 00:45:03 +0800 Subject: [PATCH 047/121] chore: update changelog for v6.2.0 (#434) --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 42e8b6db..aa90baee 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,11 @@ $decoded = json_decode(json_encode($decoded), true); Changelog --------- +#### 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 From 018dfc4e1da92ad8a1b90adc4893f476a3b41cb8 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 15 Jul 2022 10:48:45 -0600 Subject: [PATCH 048/121] chore: prepare v6.3.0 (#441) --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index aa90baee..fed1e954 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,11 @@ $decoded = json_decode(json_encode($decoded), true); Changelog --------- +#### 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)) From 60b52b71978790eafcf3b95cfbd83db0439e8d22 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 5 Oct 2022 11:57:44 -0600 Subject: [PATCH 049/121] fix: casing of GET for PSR compat (#451) --- src/CachedKeySet.php | 2 +- tests/CachedKeySetTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index e2215b30..87f470d7 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -146,7 +146,7 @@ private function keyIdExists(string $keyId): bool if ($this->rateLimitExceeded()) { return false; } - $request = $this->httpFactory->createRequest('get', $this->jwksUri); + $request = $this->httpFactory->createRequest('GET', $this->jwksUri); $jwksResponse = $this->httpClient->sendRequest($request); $jwks = (string) $jwksResponse->getBody(); $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg); diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 1dc2fdc0..73b1213d 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -325,7 +325,7 @@ private function getMockHttpFactory(int $timesCalled = 1) { $request = $this->prophesize('Psr\Http\Message\RequestInterface'); $factory = $this->prophesize(RequestFactoryInterface::class); - $factory->createRequest('get', $this->testJwksUri) + $factory->createRequest('GET', $this->testJwksUri) ->shouldBeCalledTimes($timesCalled) ->willReturn($request->reveal()); From 2e07d8a1524d12b69b110ad649f17461d068b8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Vin=C3=ADcius=20Santos=20da=20Costa=20Barros?= Date: Wed, 5 Oct 2022 16:38:41 -0300 Subject: [PATCH 050/121] fix: string interpolation format for php 8.2 (#446) --- src/JWT.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index 9964073d..5a5640ab 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -136,7 +136,7 @@ public static function decode( // OpenSSL expects an ASN.1 DER sequence for ES256/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'); } From 1cd213749b131a088a636586a344b0018fe8a3bb Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 5 Oct 2022 13:50:38 -0600 Subject: [PATCH 051/121] chore(docs): add examples for exception handling (#463) --- README.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- src/JWT.php | 2 +- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fed1e954..79a29ec0 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,56 @@ $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($payload, $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 +try { + $decoded = JWT::decode($payload, $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 @@ -269,7 +319,7 @@ Changelog #### 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)). + - Added `$defaultAlg` parameter to `JWT::parseKey` and `JWT::parseKeySet` ([#426](https://github.com/firebase/php-jwt/pull/426)). #### 6.1.0 / 2022-03-23 diff --git a/src/JWT.php b/src/JWT.php index 5a5640ab..f5b466d5 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -76,7 +76,7 @@ class JWT * * @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 From f7886d510050fda6d976afe3aba77e8d082f42d0 Mon Sep 17 00:00:00 2001 From: Thibault RICHARD Date: Wed, 5 Oct 2022 21:51:08 +0200 Subject: [PATCH 052/121] chore(docs): encode does not accept objects (#457) --- src/JWT.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index f5b466d5..977d7fbd 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -166,7 +166,7 @@ public static function decode( } /** - * 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. From b4998d720d011929380a5565336baa4a76a16d85 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 1 Nov 2022 14:55:48 -0600 Subject: [PATCH 053/121] chore: add release-please (#467) --- .github/release-please.yml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .github/release-please.yml diff --git a/.github/release-please.yml b/.github/release-please.yml new file mode 100644 index 00000000..0a6e0cc2 --- /dev/null +++ b/.github/release-please.yml @@ -0,0 +1,3 @@ +releaseType: simple +handleGHRelease: true +primaryBranch: main From 436fa9cf6f9f2df47bea24016165c8e003dfee70 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 1 Nov 2022 15:06:07 -0600 Subject: [PATCH 054/121] chore: move changelog into separate file (#469) --- CHANGELOG.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 85 ---------------------------------------------------- 2 files changed, 84 insertions(+), 85 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..261150b9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,84 @@ +# Changelog + +## 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 79a29ec0..ae2b3895 100644 --- a/README.md +++ b/README.md @@ -308,91 +308,6 @@ $decoded = JWT::decode($payload, $keys); $decoded = json_decode(json_encode($decoded), true); ``` -Changelog ---------- - -#### 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. - - Tests ----- Run the tests using phpunit: From ddfaddcb520488b42bca3a75e17e9dd53c3667da Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 1 Nov 2022 14:20:08 -0700 Subject: [PATCH 055/121] chore(main): release 6.3.1 (#468) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 261150b9..21d26679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [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)) From bad1b040d0c736bbf86814c6b5ae614f517cf7bd Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 1 Nov 2022 14:24:10 -0700 Subject: [PATCH 056/121] fix: check kid before using as array index --- src/JWT.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 977d7fbd..30b4cbb7 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -425,14 +425,15 @@ private static function getKey( return $keyOrKeyArray; } + if (empty($kid)) { + 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 (empty($kid)) { - throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); - } if (!isset($keyOrKeyArray[$kid])) { throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); } From ea7dda77098b96e666c5ef382452f94841e439cd Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 19 Dec 2022 09:10:46 -0800 Subject: [PATCH 057/121] chore(main): release 6.3.2 (#470) --- CHANGELOG.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d26679..d43c9d8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [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) @@ -88,5 +95,3 @@ between signing and verifying entities. Thanks to [@lcabral](https://github.com/ asymmetric keys are used together. - Update signature for `JWT::decode(...)` to require an array of supported algorithms to use when verifying token signatures. - - From 7df47ddc8ca219f7fa08f469cb0aab3e1e6bf580 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 19 Dec 2022 14:07:05 -0800 Subject: [PATCH 058/121] chore: add PHP 8.2 to test suite (#476) --- .github/workflows/tests.yml | 2 +- src/JWT.php | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 83dc88da..6956aa95 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: [ "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2" ] name: PHP ${{matrix.php }} Unit Test steps: - uses: actions/checkout@v2 diff --git a/src/JWT.php b/src/JWT.php index 30b4cbb7..623e7055 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -255,6 +255,9 @@ public static function sign( // 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); @@ -312,6 +315,12 @@ private static function verify( // 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); From 01786794e22800d95238335359609a356ee6fa01 Mon Sep 17 00:00:00 2001 From: "Chun-Sheng, Li" Date: Tue, 20 Dec 2022 06:08:43 +0800 Subject: [PATCH 059/121] chore: suggest ext-sodium (#474) Co-authored-by: Brent Shaffer --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2a3cb2df..c9aa3dbb 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "php": "^7.1||^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": { From 213924f51936291fbbca99158b11bd4ae56c2c95 Mon Sep 17 00:00:00 2001 From: Mitch Flindell <990013+scrummitch@users.noreply.github.com> Date: Tue, 20 Dec 2022 09:15:50 +1100 Subject: [PATCH 060/121] feat: add support for W3C ES256K (#462) --- src/JWK.php | 1 + src/JWT.php | 15 ++++++++------- tests/JWTTest.php | 1 + tests/data/ec-jwkset.json | 9 +++++++++ tests/data/secp256k1-private.pem | 5 +++++ tests/data/secp256k1-public.pem | 4 ++++ 6 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 tests/data/secp256k1-private.pem create mode 100644 tests/data/secp256k1-public.pem diff --git a/src/JWK.php b/src/JWK.php index 15631ecc..c7eff8ae 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -26,6 +26,7 @@ class JWK 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 (not yet supported) // 'P-521' => '1.3.132.0.35', // Len: 132 (not supported) ]; diff --git a/src/JWT.php b/src/JWT.php index 623e7055..269e8caf 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -55,6 +55,7 @@ class JWT 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'], @@ -132,8 +133,8 @@ 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)) { @@ -170,8 +171,8 @@ public static function decode( * * @param array $payload PHP array * @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 $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 * @@ -210,8 +211,8 @@ public static function encode( * * @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 $alg Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256', + * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' * * @return string An encrypted message * @@ -238,7 +239,7 @@ public static function sign( if (!$success) { 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); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 58c334ed..e3b95144 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -377,6 +377,7 @@ 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'], ]; } diff --git a/tests/data/ec-jwkset.json b/tests/data/ec-jwkset.json index 46ed8cf9..213f68ac 100644 --- a/tests/data/ec-jwkset.json +++ b/tests/data/ec-jwkset.json @@ -17,6 +17,15 @@ "x": "mQa0q5FvxPRujxzFazQT1Mo2YJJzuKiXU3svOJ41jhw", "y": "jAz7UwIl2oOFk06kj42ZFMOXmGMFUGjKASvyYtibCH0", "alg": "ES256" + }, + { + "kty": "EC", + "use": "sig", + "crv": "secp256k1", + "kid": "jwk3", + "x": "EFpwNuP322bU3WP1DtJgx67L0CUV1MxNixqPVMH2L9Q", + "y": "_fSTbijIJjpsqL16cIEvxxf3MaYMY8MbqEq066yV9ls", + "alg": "ES256K" } ] } \ No newline at end of file diff --git a/tests/data/secp256k1-private.pem b/tests/data/secp256k1-private.pem new file mode 100644 index 00000000..3e69d585 --- /dev/null +++ b/tests/data/secp256k1-private.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgC8ouvv1ZOmOjh5Nbwx6i +3b35wWN+OEkW2hzm3BKAQJ2hRANCAAT9nYGLVP6Unm/LXOoyWhsKpalffMSr3EHV +iUE8gVmj2/atnPkblx38Yj6bC3z1urERAB+JqgpWOAKaWcEYCUuO +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/tests/data/secp256k1-public.pem b/tests/data/secp256k1-public.pem new file mode 100644 index 00000000..5949135f --- /dev/null +++ b/tests/data/secp256k1-public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAE/Z2Bi1T+lJ5vy1zqMlobCqWpX3zEq9xB +1YlBPIFZo9v2rZz5G5cd/GI+mwt89bqxEQAfiaoKVjgCmlnBGAlLjg== +-----END PUBLIC KEY----- \ No newline at end of file From 08c7ba62a8a276418cb8b5ed4be31e2b268bf829 Mon Sep 17 00:00:00 2001 From: "Chun-Sheng, Li" Date: Wed, 11 Jan 2023 07:39:17 +0800 Subject: [PATCH 061/121] chore(tests): use assertSame for strict equals (#478) --- tests/CachedKeySetTest.php | 16 ++++++++-------- tests/JWKTest.php | 6 +++--- tests/JWTTest.php | 20 ++++++++++---------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 73b1213d..91d4a27c 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -93,7 +93,7 @@ public function testWithExistingKeyId() $this->getMockEmptyCache() ); $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); - $this->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); } public function testWithDefaultAlg() @@ -108,7 +108,7 @@ public function testWithDefaultAlg() 'baz256' ); $this->assertInstanceOf(Key::class, $cachedKeySet['baz']); - $this->assertEquals('baz256', $cachedKeySet['baz']->getAlgorithm()); + $this->assertSame('baz256', $cachedKeySet['baz']->getAlgorithm()); } public function testKeyIdIsCached() @@ -132,7 +132,7 @@ public function testKeyIdIsCached() $cache->reveal() ); $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); - $this->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); } public function testCachedKeyIdRefresh() @@ -165,10 +165,10 @@ public function testCachedKeyIdRefresh() $cache->reveal() ); $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); - $this->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); $this->assertInstanceOf(Key::class, $cachedKeySet['bar']); - $this->assertEquals('bar', $cachedKeySet['bar']->getAlgorithm()); + $this->assertSame('bar', $cachedKeySet['bar']->getAlgorithm()); } public function testCacheItemWithExpiresAfter() @@ -204,7 +204,7 @@ public function testCacheItemWithExpiresAfter() $expiresAfter ); $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); - $this->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); } public function testJwtVerify() @@ -233,7 +233,7 @@ public function testJwtVerify() $result = JWT::decode($msg, $cachedKeySet); - $this->assertEquals('foo', $result->sub); + $this->assertSame('foo', $result->sub); } public function testRateLimit() @@ -290,7 +290,7 @@ public function testFullIntegration(string $jwkUri): void $this->assertArrayHasKey($kid, $cachedKeySet); $key = $cachedKeySet[$kid]; $this->assertInstanceOf(Key::class, $key); - $this->assertEquals($keys['keys'][0]['alg'], $key->getAlgorithm()); + $this->assertSame($keys['keys'][0]['alg'], $key->getAlgorithm()); } public function provideFullIntegration() diff --git a/tests/JWKTest.php b/tests/JWKTest.php index b8c24f98..93afea70 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -67,7 +67,7 @@ public function testParsePrivateKeyWithoutAlgWithDefaultAlgParameter() unset($jwkSet['keys'][0]['alg']); $jwks = JWK::parseKeySet($jwkSet, 'foo'); - $this->assertEquals('foo', $jwks['jwk1']->getAlgorithm()); + $this->assertSame('foo', $jwks['jwk1']->getAlgorithm()); } public function testParseKeyWithEmptyDValue() @@ -143,7 +143,7 @@ public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg) $keys = JWK::parseKeySet($jwkSet); $result = JWT::decode($msg, $keys); - $this->assertEquals('foo', $result->sub); + $this->assertSame('foo', $result->sub); } public function provideDecodeByJwkKeySet() @@ -165,6 +165,6 @@ public function testDecodeByMultiJwkKeySet() $result = JWT::decode($msg, self::$keys); - $this->assertEquals('bar', $result->sub); + $this->assertSame('bar', $result->sub); } } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index e3b95144..3ce912ed 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -73,7 +73,7 @@ public function testValidToken() ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); + $this->assertSame($decoded->message, 'abc'); } public function testValidTokenWithLeeway() @@ -85,7 +85,7 @@ public function testValidTokenWithLeeway() ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); + $this->assertSame($decoded->message, 'abc'); JWT::$leeway = 0; } @@ -99,7 +99,7 @@ public function testExpiredTokenWithLeeway() $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'); + $this->assertSame($decoded->message, 'abc'); JWT::$leeway = 0; } @@ -113,7 +113,7 @@ public function testValidTokenWithNbf() ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); + $this->assertSame($decoded->message, 'abc'); } public function testValidTokenWithNbfLeeway() @@ -125,7 +125,7 @@ public function testValidTokenWithNbfLeeway() ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); + $this->assertSame($decoded->message, 'abc'); JWT::$leeway = 0; } @@ -151,7 +151,7 @@ public function testValidTokenWithIatLeeway() ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); + $this->assertSame($decoded->message, 'abc'); JWT::$leeway = 0; } @@ -301,7 +301,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() @@ -350,7 +350,7 @@ public function testDecodesArraysInJWTAsArray() $payload = ['foo' => [1, 2, 3]]; $jwt = JWT::encode($payload, $key, 'HS256'); $decoded = JWT::decode($jwt, new Key($key, 'HS256')); - $this->assertEquals($payload['foo'], $decoded->foo); + $this->assertSame($payload['foo'], $decoded->foo); } /** @@ -367,7 +367,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() @@ -393,6 +393,6 @@ public function testEncodeDecodeWithResource() // Verify decoding succeeds $decoded = JWT::decode($encoded, new Key($resource, 'RS512')); - $this->assertEquals('bar', $decoded->foo); + $this->assertSame('bar', $decoded->foo); } } From 78d3ed1073553f7d0bbffa6c2010009a0d483d5c Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 8 Feb 2023 09:14:01 -0500 Subject: [PATCH 062/121] feat: improve caching by only decoding jwks when necessary (#486) --- src/CachedKeySet.php | 45 ++++++++++++++++++---- tests/CachedKeySetTest.php | 77 +++++++++++++++++++++++++++++++++++--- 2 files changed, 109 insertions(+), 13 deletions(-) diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index 87f470d7..baf801f1 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -3,6 +3,7 @@ namespace Firebase\JWT; use ArrayAccess; +use InvalidArgumentException; use LogicException; use OutOfBoundsException; use Psr\Cache\CacheItemInterface; @@ -10,6 +11,7 @@ use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use RuntimeException; +use UnexpectedValueException; /** * @implements ArrayAccess @@ -41,7 +43,7 @@ class CachedKeySet implements ArrayAccess */ private $cacheItem; /** - * @var array + * @var array> */ private $keySet; /** @@ -101,7 +103,7 @@ public function offsetGet($keyId): Key if (!$this->keyIdExists($keyId)) { throw new OutOfBoundsException('Key ID not found'); } - return $this->keySet[$keyId]; + return JWK::parseKey($this->keySet[$keyId], $this->defaultAlg); } /** @@ -130,15 +132,43 @@ 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! Return it - $jwks = $item->get(); - $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg); + // 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); + } } } @@ -148,15 +178,14 @@ private function keyIdExists(string $keyId): bool } $request = $this->httpFactory->createRequest('GET', $this->jwksUri); $jwksResponse = $this->httpClient->sendRequest($request); - $jwks = (string) $jwksResponse->getBody(); - $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg); + $this->keySet = $this->formatJwksForCache((string) $jwksResponse->getBody()); if (!isset($this->keySet[$keyId])) { return false; } $item = $this->getCacheItem(); - $item->set($jwks); + $item->set($this->keySet); if ($this->expiresAfter) { $item->expiresAfter($this->expiresAfter); } diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 91d4a27c..04f7994c 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -17,11 +17,12 @@ class CachedKeySetTest extends TestCase private $testJwksUri = 'https://jwk.uri/'; private $testJwksUriKey = 'jwkshttpsjwk.uri'; private $testJwks1 = '{"keys": [{"kid":"foo","kty":"RSA","alg":"foo","n":"","e":""}]}'; + private $testCachedJwks1 = ['foo' => ['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'; + private $googleEcUri = 'https://www.gstatic.com/iap/verify/public_key-jwk'; public function testEmptyUriThrowsException() { @@ -117,7 +118,7 @@ public function testKeyIdIsCached() $cacheItem->isHit() ->willReturn(true); $cacheItem->get() - ->willReturn($this->testJwks1); + ->willReturn($this->testCachedJwks1); $cache = $this->prophesize(CacheItemPoolInterface::class); $cache->getItem($this->testJwksUriKey) @@ -136,6 +137,66 @@ public function testKeyIdIsCached() } 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() @@ -213,12 +274,18 @@ public function testJwtVerify() $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(file_get_contents(__DIR__ . '/data/rsa-jwkset.json') - ); + ->willReturn($cachedJwks); $cache = $this->prophesize(CacheItemPoolInterface::class); $cache->getItem($this->testJwksUriKey) @@ -297,7 +364,7 @@ public function provideFullIntegration() { return [ [$this->googleRsaUri], - // [$this->googleEcUri, 'LYyP2g'] + [$this->googleEcUri, 'LYyP2g'] ]; } From 4dd1e007f22a927ac77da5a3fbb067b42d3bc224 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 9 Feb 2023 13:01:23 -0800 Subject: [PATCH 063/121] chore(main): release 6.4.0 (#477) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d43c9d8c..9242bd30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [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) From 3b454f90f147db65a615041dec6661f427d6cb00 Mon Sep 17 00:00:00 2001 From: Akshay Khale Date: Mon, 13 Feb 2023 18:21:14 +0530 Subject: [PATCH 064/121] chore: fix RS256 example in README --- README.md | 49 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ae2b3895..e38752c0 100644 --- a/README.md +++ b/README.md @@ -73,28 +73,43 @@ use Firebase\JWT\Key; $privateKey = << Date: Wed, 5 Apr 2023 23:00:14 +0200 Subject: [PATCH 065/121] chore: add ArrayAccess to PHPDoc for JWT::decode (#443) --- src/JWT.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 269e8caf..b7ca20d1 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -69,11 +69,15 @@ 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'. * * @return stdClass The JWT's payload as a PHP object * From 2bc0128b532ebc4c0c9a77cbb95af2bb94a1e20b Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 26 Apr 2023 09:55:25 -0700 Subject: [PATCH 066/121] chore(docs): add multiple keys example to README (#503) --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index e38752c0..86d5d102 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,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 ---------- From d957f8e9956ab94a587fe2bd3334411483088d70 Mon Sep 17 00:00:00 2001 From: Alexey Kopytko Date: Thu, 4 May 2023 06:26:21 +0900 Subject: [PATCH 067/121] chore: skip null checks when input is never null (#502) --- src/JWT.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index b7ca20d1..61524e7b 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -380,7 +380,7 @@ public static function jsonEncode(array $input): string } 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) { From 7970104a2cfd3228eee7c2cda5a1914c62e99ded Mon Sep 17 00:00:00 2001 From: Saransh Dhingra Date: Thu, 4 May 2023 06:22:43 +0530 Subject: [PATCH 068/121] chore(docs): example of unsafe header decode in README (#501) --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 86d5d102..f0382667 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,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 From 4543842ca47254de7d2d04b8771a8528690cb00a Mon Sep 17 00:00:00 2001 From: Ajumal Date: Tue, 9 May 2023 20:36:06 +0530 Subject: [PATCH 069/121] fix: Allow KID index 0 --- src/JWT.php | 2 +- tests/JWTTest.php | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 61524e7b..421c42c2 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -439,7 +439,7 @@ private static function getKey( return $keyOrKeyArray; } - if (empty($kid)) { + if (!isset($kid)) { throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 3ce912ed..a5721d98 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -204,10 +204,11 @@ public function testEmptyKeyFails() public function testKIDChooser() { $keys = [ - '1' => new Key('my_key', 'HS256'), + '0' => new Key('my_key0', 'HS256'), + '1' => new Key('my_key1', 'HS256'), '2' => new Key('my_key2', 'HS256') ]; - $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'; @@ -217,10 +218,11 @@ public function testKIDChooser() public function testArrayAccessKIDChooser() { $keys = new ArrayObject([ - '1' => new Key('my_key', 'HS256'), + '0' => new Key('my_key0', 'HS256'), + '1' => new Key('my_key1', 'HS256'), '2' => new Key('my_key2', 'HS256'), ]); - $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'; From be6eb589e86b0f8ca9e050765b3375dfab5bf91e Mon Sep 17 00:00:00 2001 From: Ajumal Date: Wed, 10 May 2023 18:08:43 +0530 Subject: [PATCH 070/121] fix: Allow KID index 0 --- src/JWT.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index 421c42c2..c83ff099 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -439,7 +439,7 @@ private static function getKey( return $keyOrKeyArray; } - if (!isset($kid)) { + if (empty($kid) && $kid !== '0') { throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); } From 2ba554439eaedb8b8199b665a8d0cf75204cddc5 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 12 May 2023 09:11:51 -0600 Subject: [PATCH 071/121] chore: drop support for PHP 7.3 (#495) --- .github/workflows/tests.yml | 2 +- composer.json | 6 +++--- tests/CachedKeySetTest.php | 3 +++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6956aa95..b29c8018 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", "8.2" ] + php: [ "7.4", "8.0", "8.1", "8.2" ] name: PHP ${{matrix.php }} Unit Test steps: - uses: actions/checkout@v2 diff --git a/composer.json b/composer.json index c9aa3dbb..e23dfe37 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ ], "license": "BSD-3-Clause", "require": { - "php": "^7.1||^8.0" + "php": "^7.4||^8.0" }, "suggest": { "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", @@ -33,8 +33,8 @@ }, "require-dev": { "guzzlehttp/guzzle": "^6.5||^7.4", - "phpspec/prophecy-phpunit": "^1.1", - "phpunit/phpunit": "^7.5||^9.5", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", "psr/cache": "^1.0||^2.0", "psr/http-client": "^1.0", "psr/http-factory": "^1.0" diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 04f7994c..9142fda6 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -6,6 +6,7 @@ use OutOfBoundsException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Http\Client\ClientInterface; @@ -14,6 +15,8 @@ class CachedKeySetTest extends TestCase { + use ProphecyTrait; + private $testJwksUri = 'https://jwk.uri/'; private $testJwksUriKey = 'jwkshttpsjwk.uri'; private $testJwks1 = '{"keys": [{"kid":"foo","kty":"RSA","alg":"foo","n":"","e":""}]}'; From 52cd98075cc2b53e780e0fd4e82a0f91f0dab25f Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 12 May 2023 08:44:11 -0700 Subject: [PATCH 072/121] chore: release 6.5.0 Release-As: 6.5.0 From e94e7353302b0c11ec3cfff7180cd0b1743975d2 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Fri, 12 May 2023 08:47:07 -0700 Subject: [PATCH 073/121] chore(main): release 6.5.0 (#506) --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9242bd30..35e97fe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [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) From 6c8f5e7c7ca5a584c23878bc180b6927191422fb Mon Sep 17 00:00:00 2001 From: Sachin Bahukhandi Date: Fri, 12 May 2023 23:46:34 +0530 Subject: [PATCH 074/121] chore(docs): add missing imports for example in README (#507) --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f0382667..5b07aa7d 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,8 @@ 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 +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; @@ -373,6 +373,8 @@ All exceptions in the `Firebase\JWT` namespace extend `UnexpectedValueException` like this: ```php +use Firebase\JWT\JWT; +use UnexpectedValueException; try { $decoded = JWT::decode($payload, $keys); } catch (LogicException $e) { From 398ccd25ea12fa84b9e4f1085d5ff448c21ec797 Mon Sep 17 00:00:00 2001 From: croensch Date: Tue, 23 May 2023 15:57:20 +0200 Subject: [PATCH 075/121] fix: only check iat if nbf is not used (#493) --- src/JWT.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index c83ff099..7e190a3e 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -156,7 +156,7 @@ public static function decode( // 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)) { + if (!isset($payload->nbf) && isset($payload->iat) && $payload->iat > ($timestamp + static::$leeway)) { throw new BeforeValidException( 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $payload->iat) ); From fb85f47cfaeffdd94faf8defdf07164abcdad6c3 Mon Sep 17 00:00:00 2001 From: Pinchon Karim Date: Tue, 13 Jun 2023 18:35:01 +0200 Subject: [PATCH 076/121] feat: allow get headers when decoding token (#442) Co-authored-by: Vishwaraj Anand Co-authored-by: Brent Shaffer --- README.md | 6 ++++++ src/JWT.php | 7 ++++++- tests/JWTTest.php | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b07aa7d..f2cc5d03 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,14 @@ $payload = [ */ $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 +$decoded = JWT::decode($jwt, new Key($key, 'HS256'), $headers = new stdClass()); +print_r($headers); print_r($decoded); +print_r($headers); /* NOTE: This will now be an object instead of an associative array. To get diff --git a/src/JWT.php b/src/JWT.php index 7e190a3e..7ffb9852 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -78,6 +78,7 @@ class JWT * 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 * @@ -94,7 +95,8 @@ class JWT */ public static function decode( string $jwt, - $keyOrKeyArray + $keyOrKeyArray, + stdClass &$headers = null ): stdClass { // Validate JWT $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; @@ -111,6 +113,9 @@ 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'); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index a5721d98..7d49bf04 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -397,4 +397,19 @@ public function testEncodeDecodeWithResource() $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, 'my_key', 'HS256'); + JWT::decode($encoded, new Key('my_key', 'HS256'), $headers); + + $this->assertEquals($headers->typ, 'JWT'); + $this->assertEquals($headers->alg, 'HS256'); + } } From dacbbfcb979ff545ba262eb8c4d9e95ff0ff2d20 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 13 Jun 2023 11:08:24 -0600 Subject: [PATCH 077/121] chore: update README --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index f2cc5d03..701de23a 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,6 @@ print_r($decoded); $decoded = JWT::decode($jwt, new Key($key, 'HS256'), $headers = new stdClass()); print_r($headers); -print_r($decoded); -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: From 5a9cf79b4a2eb347230384648cc7b0d68cd97faa Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 13 Jun 2023 11:11:06 -0600 Subject: [PATCH 078/121] chore(main): release 6.6.0 (#511) --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e97fe8..c74fd131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [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) From e53979abae927de916a75b9d239cfda8ce32be2a Mon Sep 17 00:00:00 2001 From: Eduardo Dobay Date: Wed, 14 Jun 2023 12:10:17 -0300 Subject: [PATCH 079/121] feat: add ed25519 support to JWK (public keys) (#452) --- src/JWK.php | 28 +++++++++++++++++++++++++++- src/JWT.php | 21 ++++++++++++++++++--- tests/JWKTest.php | 1 + tests/data/ed25519-jwkset.json | 11 +++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 tests/data/ed25519-jwkset.json diff --git a/src/JWK.php b/src/JWK.php index c7eff8ae..873ab41a 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -31,6 +31,12 @@ class JWK // '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 * @@ -145,8 +151,28 @@ public static function parseKey(array $jwk, string $defaultAlg = null): ?Key $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']); default: - // Currently only RSA is supported break; } diff --git a/src/JWT.php b/src/JWT.php index 7ffb9852..56cb9314 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -220,7 +220,7 @@ public static function encode( * * @param string $msg The message to sign * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. - * @param string $alg Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256', + * @param string $alg Supported algorithms are 'EdDSA', 'ES384', 'ES256', 'ES256K', 'HS256', * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' * * @return string An encrypted message @@ -283,7 +283,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|resource|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 @@ -404,13 +404,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, '-_', '+/'); } /** diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 93afea70..4e1b0c67 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -151,6 +151,7 @@ public function provideDecodeByJwkKeySet() return [ ['rsa1-private.pem', 'rsa-jwkset.json', 'RS256'], ['ecdsa256-private.pem', 'ec-jwkset.json', 'ES256'], + ['ed25519-1.sec', 'ed25519-jwkset.json', 'EdDSA'], ]; } diff --git a/tests/data/ed25519-jwkset.json b/tests/data/ed25519-jwkset.json new file mode 100644 index 00000000..186b8e29 --- /dev/null +++ b/tests/data/ed25519-jwkset.json @@ -0,0 +1,11 @@ +{ + "keys": [ + { + "kid": "jwk1", + "alg": "EdDSA", + "kty": "OKP", + "crv": "Ed25519", + "x": "uOSJMhbKSG4V5xUHS7B9YHmVg_1yVd-G-Io6oBFhSfY" + } + ] +} From 71278f20b0a623389beefe87a641d03948a38870 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 14 Jun 2023 08:29:26 -0700 Subject: [PATCH 080/121] chore(main): release 6.7.0 (#518) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c74fd131..ee78825b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [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) From 91c39c72b22fc3e1191e574089552c1f2041c718 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 14 Jun 2023 13:26:33 -0600 Subject: [PATCH 081/121] fix: handle invalid http responses (#508) --- src/CachedKeySet.php | 10 ++++++++++ tests/CachedKeySetTest.php | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index baf801f1..ee529f9f 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -178,6 +178,16 @@ private function keyIdExists(string $keyId): bool } $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])) { diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 9142fda6..1e73af6d 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -88,6 +88,37 @@ public function testOutOfBoundsThrowsException() $cachedKeySet['bar']; } + public function testInvalidHttpResponseThrowsException() + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('HTTP Error: 404 URL not found'); + $this->expectExceptionCode(404); + + $body = $this->prophesize('Psr\Http\Message\StreamInterface'); + + $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( @@ -382,6 +413,9 @@ private function getMockHttpClient($testJwks, int $timesCalled = 1) $response->getBody() ->shouldBeCalledTimes($timesCalled) ->willReturn($body->reveal()); + $response->getStatusCode() + ->shouldBeCalledTimes($timesCalled) + ->willReturn(200); $http = $this->prophesize(ClientInterface::class); $http->sendRequest(Argument::any()) From 15d579a76bf2bef1f043c18c9eabbaec0e6989b6 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 14 Jun 2023 12:28:03 -0700 Subject: [PATCH 082/121] chore(tests): remove unused variable --- tests/CachedKeySetTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 1e73af6d..2e4e1f62 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -94,8 +94,6 @@ public function testInvalidHttpResponseThrowsException() $this->expectExceptionMessage('HTTP Error: 404 URL not found'); $this->expectExceptionCode(404); - $body = $this->prophesize('Psr\Http\Message\StreamInterface'); - $response = $this->prophesize('Psr\Http\Message\ResponseInterface'); $response->getStatusCode() ->shouldBeCalled() From 5de4323f4baf4d70bca8663bd87682a69c656c3d Mon Sep 17 00:00:00 2001 From: lleyton Date: Wed, 14 Jun 2023 12:28:31 -0700 Subject: [PATCH 083/121] feat: add support for P-384 curve (#515) --- src/JWK.php | 4 ++-- tests/JWKTest.php | 11 ++++++----- tests/data/ec-jwkset.json | 9 +++++++++ 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/JWK.php b/src/JWK.php index 873ab41a..63fb2484 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -27,7 +27,7 @@ class JWK 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 (not yet supported) + 'P-384' => '1.3.132.0.34', // Len: 96 // 'P-521' => '1.3.132.0.35', // Len: 132 (not supported) ]; @@ -182,7 +182,7 @@ public static function parseKey(array $jwk, string $defaultAlg = null): ?Key /** * Converts the EC JWK values to pem format. * - * @param string $crv The EC curve (only P-256 is supported) + * @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 * diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 4e1b0c67..01082a40 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -129,11 +129,11 @@ public function testDecodeByJwkKeySetTokenExpired() /** * @dataProvider provideDecodeByJwkKeySet */ - public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg) + public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg, $keyId) { $privKey1 = file_get_contents(__DIR__ . '/data/' . $pemFile); $payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')]; - $msg = JWT::encode($payload, $privKey1, $alg, 'jwk1'); + $msg = JWT::encode($payload, $privKey1, $alg, $keyId); $jwkSet = json_decode( file_get_contents(__DIR__ . '/data/' . $jwkFile), @@ -149,9 +149,10 @@ public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg) public function provideDecodeByJwkKeySet() { return [ - ['rsa1-private.pem', 'rsa-jwkset.json', 'RS256'], - ['ecdsa256-private.pem', 'ec-jwkset.json', 'ES256'], - ['ed25519-1.sec', 'ed25519-jwkset.json', 'EdDSA'], + ['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'], ]; } diff --git a/tests/data/ec-jwkset.json b/tests/data/ec-jwkset.json index 213f68ac..50c5b24e 100644 --- a/tests/data/ec-jwkset.json +++ b/tests/data/ec-jwkset.json @@ -26,6 +26,15 @@ "x": "EFpwNuP322bU3WP1DtJgx67L0CUV1MxNixqPVMH2L9Q", "y": "_fSTbijIJjpsqL16cIEvxxf3MaYMY8MbqEq066yV9ls", "alg": "ES256K" + }, + { + "kty": "EC", + "use": "sig", + "crv": "P-384", + "kid": "jwk4", + "x": "FhXXcyKmWkTkdVbWYYU3dtJqpJ0JmLGftEdNzUEFEKSU5MlnLr_FjcneszvXAqEB", + "y": "M4veJF_dO_zhFk44bh_ELXbp0_nn9QaViVtQpuTvpu29eefx6PfUMqX0K--IS4NQ", + "alg": "ES384" } ] } \ No newline at end of file From 48b0210c51718d682e53210c24d25c5a10a2299b Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 20 Jun 2023 09:45:35 -0700 Subject: [PATCH 084/121] chore(main): release 6.8.0 (#519) --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee78825b..9638f2dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [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) From 39368423beeaacb3002afa7dcb75baebf204fe7e Mon Sep 17 00:00:00 2001 From: croensch Date: Wed, 28 Jun 2023 20:25:09 +0200 Subject: [PATCH 085/121] fix: accept float claims but round down to ignore them (#492) --- src/JWT.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 56cb9314..db075ad0 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -152,18 +152,18 @@ public static function decode( // 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)) { + if (isset($payload->nbf) && floor($payload->nbf) > ($timestamp + static::$leeway)) { throw new BeforeValidException( - 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $payload->nbf) + 'Cannot handle token prior to ' . \date(DateTime::ISO8601, (int) $payload->nbf) ); } // 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->nbf) && isset($payload->iat) && $payload->iat > ($timestamp + static::$leeway)) { + if (!isset($payload->nbf) && isset($payload->iat) && floor($payload->iat) > ($timestamp + static::$leeway)) { throw new BeforeValidException( - 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $payload->iat) + 'Cannot handle token prior to ' . \date(DateTime::ISO8601, (int) $payload->iat) ); } From 299105a51c6c98ad54692fd8c5702062bf11b5ec Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 30 Jun 2023 13:08:13 -0700 Subject: [PATCH 086/121] chore: add tests for latest fixes (#512) --- tests/JWTTest.php | 80 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 7d49bf04..5265e471 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -76,6 +76,9 @@ public function testValidToken() $this->assertSame($decoded->message, 'abc'); } + /** + * @runInSeparateProcess + */ public function testValidTokenWithLeeway() { JWT::$leeway = 60; @@ -86,9 +89,11 @@ public function testValidTokenWithLeeway() $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertSame($decoded->message, 'abc'); - JWT::$leeway = 0; } + /** + * @runInSeparateProcess + */ public function testExpiredTokenWithLeeway() { JWT::$leeway = 60; @@ -100,7 +105,6 @@ public function testExpiredTokenWithLeeway() $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertSame($decoded->message, 'abc'); - JWT::$leeway = 0; } public function testValidTokenWithNbf() @@ -116,6 +120,9 @@ public function testValidTokenWithNbf() $this->assertSame($decoded->message, 'abc'); } + /** + * @runInSeparateProcess + */ public function testValidTokenWithNbfLeeway() { JWT::$leeway = 60; @@ -126,9 +133,11 @@ public function testValidTokenWithNbfLeeway() $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertSame($decoded->message, 'abc'); - JWT::$leeway = 0; } + /** + * @runInSeparateProcess + */ public function testInvalidTokenWithNbfLeeway() { JWT::$leeway = 60; @@ -139,9 +148,45 @@ public function testInvalidTokenWithNbfLeeway() $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(BeforeValidException::class); JWT::decode($encoded, new Key('my_key', 'HS256')); - JWT::$leeway = 0; } + public function testValidTokenWithNbfIgnoresIat() + { + $payload = [ + 'message' => 'abc', + 'nbf' => time() - 20, // time in the future + 'iat' => time() + 20, // time in the past + ]; + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $this->assertEquals('abc', $decoded->message); + } + + public function testValidTokenWithNbfMicrotime() + { + $payload = [ + 'message' => 'abc', + 'nbf' => microtime(true), // use microtime + ]; + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $this->assertEquals('abc', $decoded->message); + } + + public function testInvalidTokenWithNbfMicrotime() + { + $this->expectException(BeforeValidException::class); + $payload = [ + 'message' => 'abc', + 'nbf' => microtime(true) + 20, // use microtime in the future + ]; + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + JWT::decode($encoded, new Key('my_key', 'HS256')); + } + + /** + * @runInSeparateProcess + */ public function testValidTokenWithIatLeeway() { JWT::$leeway = 60; @@ -152,9 +197,11 @@ public function testValidTokenWithIatLeeway() $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertSame($decoded->message, 'abc'); - JWT::$leeway = 0; } + /** + * @runInSeparateProcess + */ public function testInvalidTokenWithIatLeeway() { JWT::$leeway = 60; @@ -165,7 +212,28 @@ public function testInvalidTokenWithIatLeeway() $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(BeforeValidException::class); JWT::decode($encoded, new Key('my_key', 'HS256')); - JWT::$leeway = 0; + } + + public function testValidTokenWithIatMicrotime() + { + $payload = [ + 'message' => 'abc', + 'iat' => microtime(true), // use microtime + ]; + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $this->assertEquals('abc', $decoded->message); + } + + public function testInvalidTokenWithIatMicrotime() + { + $this->expectException(BeforeValidException::class); + $payload = [ + 'message' => 'abc', + 'iat' => microtime(true) + 20, // use microtime in the future + ]; + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + JWT::decode($encoded, new Key('my_key', 'HS256')); } public function testInvalidToken() From 0a53cf2986e45c2bcbf1a269f313ebf56a154ee4 Mon Sep 17 00:00:00 2001 From: Vishwaraj Anand Date: Fri, 14 Jul 2023 23:19:54 +0530 Subject: [PATCH 087/121] chore: better BeforeValidException message for decode (#526) --- src/JWT.php | 4 ++-- tests/JWTTest.php | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index db075ad0..18927452 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -154,7 +154,7 @@ public static function decode( // token can actually be used. If it's not yet that time, abort. if (isset($payload->nbf) && floor($payload->nbf) > ($timestamp + static::$leeway)) { throw new BeforeValidException( - 'Cannot handle token prior to ' . \date(DateTime::ISO8601, (int) $payload->nbf) + 'Cannot handle token with nbf prior to ' . \date(DateTime::ISO8601, (int) $payload->nbf) ); } @@ -163,7 +163,7 @@ public static function decode( // correctly used the nbf claim). if (!isset($payload->nbf) && isset($payload->iat) && floor($payload->iat) > ($timestamp + static::$leeway)) { throw new BeforeValidException( - 'Cannot handle token prior to ' . \date(DateTime::ISO8601, (int) $payload->iat) + 'Cannot handle token with iat prior to ' . \date(DateTime::ISO8601, (int) $payload->iat) ); } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 5265e471..44b3f049 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -147,6 +147,7 @@ public function testInvalidTokenWithNbfLeeway() ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(BeforeValidException::class); + $this->expectExceptionMessage('Cannot handle token with nbf prior to'); JWT::decode($encoded, new Key('my_key', 'HS256')); } @@ -176,6 +177,7 @@ public function testValidTokenWithNbfMicrotime() public function testInvalidTokenWithNbfMicrotime() { $this->expectException(BeforeValidException::class); + $this->expectExceptionMessage('Cannot handle token with nbf prior to'); $payload = [ 'message' => 'abc', 'nbf' => microtime(true) + 20, // use microtime in the future @@ -211,6 +213,7 @@ public function testInvalidTokenWithIatLeeway() ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(BeforeValidException::class); + $this->expectExceptionMessage('Cannot handle token with iat prior to'); JWT::decode($encoded, new Key('my_key', 'HS256')); } @@ -228,6 +231,7 @@ public function testValidTokenWithIatMicrotime() public function testInvalidTokenWithIatMicrotime() { $this->expectException(BeforeValidException::class); + $this->expectExceptionMessage('Cannot handle token with iat prior to'); $payload = [ 'message' => 'abc', 'iat' => microtime(true) + 20, // use microtime in the future From 5dbc8959427416b8ee09a100d7a8588c00fb2e26 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Fri, 14 Jul 2023 18:33:00 +0000 Subject: [PATCH 088/121] chore(main): release 6.8.1 (#524) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9638f2dd..353766ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [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) From 175edf958bb61922ec135b2333acf5622f2238a2 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 4 Oct 2023 16:59:04 -0700 Subject: [PATCH 089/121] feat: add payload to jwt exception (#521) --- src/BeforeValidException.php | 13 ++++++++- src/ExpiredException.php | 13 ++++++++- src/JWT.php | 12 ++++++--- src/JWTExceptionWithPayloadInterface.php | 20 ++++++++++++++ tests/JWTTest.php | 34 ++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 src/JWTExceptionWithPayloadInterface.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/ExpiredException.php b/src/ExpiredException.php index 81ba52d4..12fef094 100644 --- a/src/ExpiredException.php +++ b/src/ExpiredException.php @@ -2,6 +2,17 @@ namespace Firebase\JWT; -class ExpiredException extends \UnexpectedValueException +class ExpiredException 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/JWT.php b/src/JWT.php index 18927452..6efcbb56 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -153,23 +153,29 @@ public static function decode( // 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) && floor($payload->nbf) > ($timestamp + static::$leeway)) { - throw new BeforeValidException( + $ex = new BeforeValidException( 'Cannot handle token with nbf prior to ' . \date(DateTime::ISO8601, (int) $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->nbf) && isset($payload->iat) && floor($payload->iat) > ($timestamp + static::$leeway)) { - throw new BeforeValidException( + $ex = new BeforeValidException( 'Cannot handle token with iat prior to ' . \date(DateTime::ISO8601, (int) $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); + throw $ex; } return $payload; 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 @@ +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, 'my_key', 'HS256'); + try { + JWT::decode($encoded, new Key('my_key', 'HS256')); + } catch (ExpiredException $e) { + $exceptionPayload = (array) $e->getPayload(); + $this->assertEquals($exceptionPayload, $payload); + throw $e; + } + } + + public function testBeforeValidExceptionPayload() + { + $this->expectException(BeforeValidException::class); + $payload = [ + 'message' => 'abc', + 'iat' => time() + 100, // time in the future + ]; + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + try { + JWT::decode($encoded, new Key('my_key', 'HS256')); + } catch (BeforeValidException $e) { + $exceptionPayload = (array) $e->getPayload(); + $this->assertEquals($exceptionPayload, $payload); + throw $e; + } + } + public function testValidTokenWithNbf() { $payload = [ From f03270e63eaccf3019ef0f32849c497385774e11 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 4 Oct 2023 17:24:42 -0700 Subject: [PATCH 090/121] chore(main): release 6.9.0 (#537) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 353766ee..4279dfd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [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) From 79cb30b729a22931b2fbd6b53f20629a83031ba9 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 28 Nov 2023 17:44:26 -0600 Subject: [PATCH 091/121] feat: allow typ header override (#546) --- src/JWT.php | 9 +++++---- tests/JWTTest.php | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 6efcbb56..26349206 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -203,13 +203,14 @@ public static function encode( string $keyId = null, array $head = null ): string { - $header = ['typ' => 'JWT', 'alg' => $alg]; + $header = ['typ' => 'JWT']; + if (isset($head) && \is_array($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)); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 13240410..b59c3c20 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -518,4 +518,26 @@ public function testGetHeaders() $this->assertEquals($headers->typ, 'JWT'); $this->assertEquals($headers->alg, 'HS256'); } + + public function testAdditionalHeaderOverrides() + { + $msg = JWT::encode( + ['message' => 'abc'], + 'my_key', + '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, new Key('my_key', 'HS256'), $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'); + } } From a49db6f0a5033aef5143295342f1c95521b075ff Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Fri, 1 Dec 2023 08:26:39 -0800 Subject: [PATCH 092/121] chore(main): release 6.10.0 (#547) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4279dfd2..644fa0be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [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) From dda725033585ece30ff8cae8937320d7e9f18bae Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 19 Dec 2023 10:00:36 -0600 Subject: [PATCH 093/121] fix: fix ratelimit cache expiration (#550) --- src/CachedKeySet.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index ee529f9f..01e27132 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -213,7 +213,7 @@ private function rateLimitExceeded(): bool $cacheItem = $this->cache->getItem($this->rateLimitCacheKey); if (!$cacheItem->isHit()) { - $cacheItem->expiresAfter(1); // # of calls are cached each minute + $cacheItem->expiresAfter(60); // # of calls are cached each minute } $callsPerMinute = (int) $cacheItem->get(); From 1b9e87184745595ef70540613c0cb9de09bebab3 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 21 Dec 2023 14:02:08 -0600 Subject: [PATCH 094/121] chore: add php 8.3 to ci (#548) --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b29c8018..7e576e04 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ "7.4", "8.0", "8.1", "8.2" ] + php: [ "7.4", "8.0", "8.1", "8.2", "8.3" ] name: PHP ${{matrix.php }} Unit Test steps: - uses: actions/checkout@v2 @@ -35,7 +35,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: "8.0" + php-version: "8.2" - name: Run Script run: | composer global require friendsofphp/php-cs-fixer @@ -49,7 +49,7 @@ jobs: - name: Install PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.0' + php-version: '8.2' - name: Run Script run: | composer install From e9690f56c0bf9cd670655add889b4e243e3ac576 Mon Sep 17 00:00:00 2001 From: Vishwaraj Anand Date: Sat, 16 Mar 2024 02:03:57 +0530 Subject: [PATCH 095/121] chore: remove jwt incorrect key warning (#560) --- src/JWT.php | 3 +++ tests/JWTTest.php | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/src/JWT.php b/src/JWT.php index 26349206..e9d75639 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -251,6 +251,9 @@ public static function sign( return \hash_hmac($algorithm, $msg, $key, true); case 'openssl': $signature = ''; + if (!\is_resource($key) && !openssl_pkey_get_private($key)) { + throw new DomainException('OpenSSL unable to validate key'); + } $success = \openssl_sign($msg, $signature, $key, $algorithm); // @phpstan-ignore-line if (!$success) { throw new DomainException('OpenSSL unable to sign data'); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index b59c3c20..d09d43e3 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -26,6 +26,12 @@ public function testMalformedUtf8StringsFail() JWT::encode(['message' => pack('c', 128)], 'a', 'HS256'); } + public function testInvalidKeyOpensslSignFail() + { + $this->expectException(DomainException::class); + JWT::sign('message', 'invalid key', 'openssl'); + } + public function testMalformedJsonThrowsException() { $this->expectException(DomainException::class); From 4bdb0a6d4f39d3f0d32ffe436188290b0c1745d5 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 17 May 2024 11:39:00 -0600 Subject: [PATCH 096/121] chore: drop support for PHP 7.4 (#558) --- .github/workflows/tests.yml | 2 +- README.md | 2 +- composer.json | 6 +++--- tests/CachedKeySetTest.php | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7e576e04..13fc947f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ "7.4", "8.0", "8.1", "8.2", "8.3" ] + php: [ "8.0", "8.1", "8.2", "8.3" ] name: PHP ${{matrix.php }} Unit Test steps: - uses: actions/checkout@v2 diff --git a/README.md b/README.md index 701de23a..4fd14074 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 diff --git a/composer.json b/composer.json index e23dfe37..816cfd0b 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ ], "license": "BSD-3-Clause", "require": { - "php": "^7.4||^8.0" + "php": "^8.0" }, "suggest": { "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", @@ -32,10 +32,10 @@ } }, "require-dev": { - "guzzlehttp/guzzle": "^6.5||^7.4", + "guzzlehttp/guzzle": "^7.4", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "psr/cache": "^1.0||^2.0", + "psr/cache": "^2.0||^3.0", "psr/http-client": "^1.0", "psr/http-factory": "^1.0" } diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 2e4e1f62..e5d3aa86 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -553,7 +553,7 @@ public function getKey(): string return $this->key; } - public function get() + public function get(): mixed { return $this->isHit() ? $this->value : null; } @@ -571,7 +571,7 @@ public function isHit(): bool return $this->currentTime()->getTimestamp() < $this->expiration->getTimestamp(); } - public function set($value) + public function set(mixed $value): static { $this->isHit = true; $this->value = $value; @@ -579,13 +579,13 @@ public function set($value) return $this; } - public function expiresAt($expiration) + public function expiresAt($expiration): static { $this->expiration = $expiration; return $this; } - public function expiresAfter($time) + public function expiresAfter($time): static { $this->expiration = $this->currentTime()->add(new \DateInterval("PT{$time}S")); return $this; From 09cb2081c2c3bc0f61e2f2a5fbea5741f7498648 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Sat, 18 May 2024 12:03:01 -0600 Subject: [PATCH 097/121] fix: ensure ratelimit expiry is set every time (#556) --- src/CachedKeySet.php | 14 +++++++--- tests/CachedKeySetTest.php | 55 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index 01e27132..65bab74f 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -212,15 +212,21 @@ private function rateLimitExceeded(): bool } $cacheItem = $this->cache->getItem($this->rateLimitCacheKey); - if (!$cacheItem->isHit()) { - $cacheItem->expiresAfter(60); // # of calls are cached each minute + + $cacheItemData = []; + if ($cacheItem->isHit() && \is_array($data = $cacheItem->get())) { + $cacheItemData = $data; } - $callsPerMinute = (int) $cacheItem->get(); + $callsPerMinute = $cacheItemData['callsPerMinute'] ?? 0; + $expiry = $cacheItemData['expiry'] ?? new \DateTime('+60 seconds', new \DateTimeZone('UTC')); + if (++$callsPerMinute > $this->maxCallsPerMinute) { return true; } - $cacheItem->set($callsPerMinute); + + $cacheItem->set(['expiry' => $expiry, 'callsPerMinute' => $callsPerMinute]); + $cacheItem->expiresAt($expiry); $this->cache->save($cacheItem); return false; } diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index e5d3aa86..39bbc919 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -344,7 +344,7 @@ public function testRateLimit() $cachedKeySet = new CachedKeySet( $this->testJwksUri, $this->getMockHttpClient($this->testJwks1, $shouldBeCalledTimes), - $factory = $this->getMockHttpFactory($shouldBeCalledTimes), + $this->getMockHttpFactory($shouldBeCalledTimes), new TestMemoryCacheItemPool(), 10, // expires after seconds true // enable rate limiting @@ -358,6 +358,54 @@ public function testRateLimit() $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 */ @@ -466,7 +514,10 @@ final class TestMemoryCacheItemPool implements CacheItemPoolInterface public function getItem($key): CacheItemInterface { - return current($this->getItems([$key])); + $item = current($this->getItems([$key])); + $item->expiresAt(null); // mimic symfony cache behavior + + return $item; } public function getItems(array $keys = []): iterable From 500501c2ce893c824c801da135d02661199f60c5 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Sat, 18 May 2024 11:05:11 -0700 Subject: [PATCH 098/121] chore(main): release 6.10.1 (#551) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 644fa0be..2662b050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [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) From d4495baba6dd3830fa639ee7b01ac53fb28961e4 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 7 Aug 2024 10:23:58 -0700 Subject: [PATCH 099/121] chore: add test for parseKey (#565) --- tests/JWKTest.php | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 01082a40..496f6bad 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -169,4 +169,39 @@ public function testDecodeByMultiJwkKeySet() $this->assertSame('bar', $result->sub); } + + 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']); + } } From 76808fa227f3811aa5cdb3bf81233714b799a5b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Wed, 7 Aug 2024 20:22:50 +0200 Subject: [PATCH 100/121] chore: Prepare towards PHP8.4 compatibility (#572) --- .php-cs-fixer.dist.php | 4 ++++ src/CachedKeySet.php | 6 +++--- src/JWK.php | 6 +++--- src/JWT.php | 6 +++--- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index fb636632..93ff7a4c 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -16,6 +16,10 @@ 'native_function_invocation' => [ 'strict' => false ], + 'nullable_type_declaration' => [ + 'syntax' => 'question_mark', + ], + 'nullable_type_declaration_for_default_null_value' => true, ]) ->setFinder( PhpCsFixer\Finder::create() diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index 65bab74f..8e8e8d68 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -80,9 +80,9 @@ public function __construct( ClientInterface $httpClient, RequestFactoryInterface $httpFactory, CacheItemPoolInterface $cache, - int $expiresAfter = null, + ?int $expiresAfter = null, bool $rateLimit = false, - string $defaultAlg = null + ?string $defaultAlg = null ) { $this->jwksUri = $jwksUri; $this->httpClient = $httpClient; @@ -180,7 +180,7 @@ private function keyIdExists(string $keyId): bool $jwksResponse = $this->httpClient->sendRequest($request); if ($jwksResponse->getStatusCode() !== 200) { throw new UnexpectedValueException( - sprintf('HTTP Error: %d %s for URI "%s"', + \sprintf('HTTP Error: %d %s for URI "%s"', $jwksResponse->getStatusCode(), $jwksResponse->getReasonPhrase(), $this->jwksUri, diff --git a/src/JWK.php b/src/JWK.php index 63fb2484..6efc2fe3 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -52,7 +52,7 @@ class JWK * * @uses parseKey */ - public static function parseKeySet(array $jwks, string $defaultAlg = null): array + public static function parseKeySet(array $jwks, ?string $defaultAlg = null): array { $keys = []; @@ -93,7 +93,7 @@ public static function parseKeySet(array $jwks, string $defaultAlg = null): arra * * @uses createPemFromModulusAndExponent */ - public static function parseKey(array $jwk, string $defaultAlg = null): ?Key + public static function parseKey(array $jwk, ?string $defaultAlg = null): ?Key { if (empty($jwk)) { throw new InvalidArgumentException('JWK must not be empty'); @@ -212,7 +212,7 @@ private static function createPemFromCrvAndXYCoordinates(string $crv, string $x, ) ); - return sprintf( + return \sprintf( "-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n", wordwrap(base64_encode($pem), 64, "\n", true) ); diff --git a/src/JWT.php b/src/JWT.php index e9d75639..9100bf0f 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -96,7 +96,7 @@ class JWT public static function decode( string $jwt, $keyOrKeyArray, - stdClass &$headers = null + ?stdClass &$headers = null ): stdClass { // Validate JWT $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; @@ -200,8 +200,8 @@ public static function encode( array $payload, $key, string $alg, - string $keyId = null, - array $head = null + ?string $keyId = null, + ?array $head = null ): string { $header = ['typ' => 'JWT']; if (isset($head) && \is_array($head)) { From e3d68b044421339443c74199edd020e03fb1887e Mon Sep 17 00:00:00 2001 From: Ruud Kamphuis Date: Sun, 24 Nov 2024 12:03:38 +0100 Subject: [PATCH 101/121] fix: support php 8.4 (#583) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 13fc947f..de513a56 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ "8.0", "8.1", "8.2", "8.3" ] + php: [ "8.0", "8.1", "8.2", "8.3", "8.4" ] name: PHP ${{matrix.php }} Unit Test steps: - uses: actions/checkout@v2 From c2b54c2580de784b3c23b08b6091192332884823 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Sun, 24 Nov 2024 06:19:08 -0500 Subject: [PATCH 102/121] chore: fix phpstan (#584) --- .github/workflows/tests.yml | 6 +++--- src/JWT.php | 9 ++------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index de513a56..6e0cd3c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,7 +35,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: "8.2" + php-version: "8.3" - name: Run Script run: | composer global require friendsofphp/php-cs-fixer @@ -49,9 +49,9 @@ jobs: - name: Install PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.3' - name: Run Script run: | composer install - composer global require phpstan/phpstan + composer global require phpstan/phpstan:~1.10.0 ~/.composer/vendor/bin/phpstan analyse diff --git a/src/JWT.php b/src/JWT.php index 9100bf0f..dd9292a4 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -204,7 +204,7 @@ public static function encode( ?array $head = null ): string { $header = ['typ' => 'JWT']; - if (isset($head) && \is_array($head)) { + if (isset($head)) { $header = \array_merge($header, $head); } $header['alg'] = $alg; @@ -387,12 +387,7 @@ 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') { From 30c19ed0f3264cb660ea496895cfb6ef7ee3653b Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Sun, 24 Nov 2024 11:22:49 +0000 Subject: [PATCH 103/121] chore(main): release 6.10.2 (#585) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2662b050..5feeb5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [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) From 29fa2ce9e0582cd397711eec1e80c05ce20fabca Mon Sep 17 00:00:00 2001 From: Margar Melkonyan <74971196+margar-melkonyan@users.noreply.github.com> Date: Mon, 25 Nov 2024 00:58:09 +0300 Subject: [PATCH 104/121] fix: refactor constructor Key to use PHP 8.0 syntax (#577) Co-authored-by: Brent Shaffer --- src/Key.php | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/Key.php b/src/Key.php index 00cf7f2e..b34eae25 100644 --- a/src/Key.php +++ b/src/Key.php @@ -9,18 +9,13 @@ class Key { - /** @var string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate */ - private $keyMaterial; - /** @var string */ - private $algorithm; - /** * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial * @param string $algorithm */ public function __construct( - $keyMaterial, - string $algorithm + private $keyMaterial, + private string $algorithm ) { if ( !\is_string($keyMaterial) @@ -38,10 +33,6 @@ public function __construct( if (empty($algorithm)) { throw new InvalidArgumentException('Algorithm must not be empty'); } - - // TODO: Remove in PHP 8.0 in favor of class constructor property promotion - $this->keyMaterial = $keyMaterial; - $this->algorithm = $algorithm; } /** From d9a140a796ca984cb8284774ba6f32afd6fdc446 Mon Sep 17 00:00:00 2001 From: Anthon Pang Date: Thu, 23 Jan 2025 00:00:01 -0500 Subject: [PATCH 105/121] docs: fix example to avoid fatal error (#590) --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4fd14074..04252693 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ $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 -$decoded = JWT::decode($jwt, new Key($key, 'HS256'), $headers = new stdClass()); +$headers = new stdClass(); +$decoded = JWT::decode($jwt, new Key($key, 'HS256'), $headers); print_r($headers); /* From 7cb8a265fa81edf2fa6ef8098f5bc5ae573c33ad Mon Sep 17 00:00:00 2001 From: apiwat-chantawibul Date: Thu, 23 Jan 2025 12:04:56 +0700 Subject: [PATCH 106/121] feat: support octet typed JWK (#587) --- src/JWK.php | 6 ++++++ tests/JWKTest.php | 25 +++++++++++++++++++++++++ tests/data/octet-jwkset.json | 22 ++++++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 tests/data/octet-jwkset.json diff --git a/src/JWK.php b/src/JWK.php index 6efc2fe3..405dcc49 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -172,6 +172,12 @@ public static function parseKey(array $jwk, ?string $defaultAlg = null): ?Key // 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: break; } diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 496f6bad..db385c87 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -170,6 +170,31 @@ public function testDecodeByMultiJwkKeySet() $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 diff --git a/tests/data/octet-jwkset.json b/tests/data/octet-jwkset.json new file mode 100644 index 00000000..5555b9dd --- /dev/null +++ b/tests/data/octet-jwkset.json @@ -0,0 +1,22 @@ +{ + "keys": [ + { + "kty": "oct", + "alg": "HS256", + "kid": "jwk1", + "k": "xUNfVvQ-WdmXB9qp6qK0SrG-yKW4AJqmcSP66Gm2TrE" + }, + { + "kty": "oct", + "alg": "HS384", + "kid": "jwk2", + "k": "z7990HoD72QDX9JKqeQc3l7EtXutco72j2YulZMjeakFVDbFGXGDFG4awOF7eu9l" + }, + { + "kty": "oct", + "alg": "HS512", + "kid": "jwk3", + "k": "EmYGSDG5W1UjkPIL7LelG-QMVtsXn7bz5lUxBrkqq3kdFEzkLWVGrXKpZxRe7YcApCe0d4s9lXRQtn5Nzaf49w" + } + ] +} From 8f718f4dfc9c5d5f0c994cdfd103921b43592712 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 22 Jan 2025 21:11:06 -0800 Subject: [PATCH 107/121] chore(main): release 6.11.0 (#586) --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5feeb5a6..01fcc077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [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) From 27179e1d244298d83d9b300b47763cba1e87cee4 Mon Sep 17 00:00:00 2001 From: Syahrul Safarila Date: Thu, 10 Apr 2025 02:12:25 +0700 Subject: [PATCH 108/121] docs: fix examples in README.md (#569) --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 04252693..e45ccb80 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ $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 @@ -350,7 +350,7 @@ use InvalidArgumentException; use UnexpectedValueException; try { - $decoded = JWT::decode($payload, $keys); + $decoded = JWT::decode($jwt, $keys); } catch (InvalidArgumentException $e) { // provided key/key-array is empty or malformed. } catch (DomainException $e) { @@ -380,7 +380,7 @@ like this: use Firebase\JWT\JWT; use UnexpectedValueException; try { - $decoded = JWT::decode($payload, $keys); + $decoded = JWT::decode($jwt, $keys); } catch (LogicException $e) { // errors having to do with environmental setup or malformed JWT Keys } catch (UnexpectedValueException $e) { @@ -395,7 +395,7 @@ 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); From c11113afa13265e016a669e75494b9203b8a7775 Mon Sep 17 00:00:00 2001 From: Dan Wallis Date: Wed, 9 Apr 2025 21:29:08 +0100 Subject: [PATCH 109/121] fix: update error text for consistency (#528) --- src/JWT.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index dd9292a4..833a415e 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -154,7 +154,7 @@ public static function decode( // token can actually be used. If it's not yet that time, abort. if (isset($payload->nbf) && floor($payload->nbf) > ($timestamp + static::$leeway)) { $ex = new BeforeValidException( - 'Cannot handle token with nbf prior to ' . \date(DateTime::ISO8601, (int) $payload->nbf) + 'Cannot handle token with nbf prior to ' . \date(DateTime::ISO8601, (int) floor($payload->nbf)) ); $ex->setPayload($payload); throw $ex; @@ -165,7 +165,7 @@ public static function decode( // correctly used the nbf claim). 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::ISO8601, (int) $payload->iat) + 'Cannot handle token with iat prior to ' . \date(DateTime::ISO8601, (int) floor($payload->iat)) ); $ex->setPayload($payload); throw $ex; From d1e91ecf8c598d073d0995afa8cd5c75c6e19e66 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 9 Apr 2025 13:32:01 -0700 Subject: [PATCH 110/121] chore(main): release 6.11.1 (#597) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01fcc077..7b5f6ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [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) From 43d70ae8d9b0dd8adbe189adc03324369f2161e6 Mon Sep 17 00:00:00 2001 From: Enno Rehling Date: Wed, 9 Apr 2025 22:42:27 +0200 Subject: [PATCH 111/121] fix: use DateTime::ATOM instead of ISO8601 in exception message --- src/JWT.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 833a415e..37a9e0e6 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -154,7 +154,7 @@ public static function decode( // token can actually be used. If it's not yet that time, abort. if (isset($payload->nbf) && floor($payload->nbf) > ($timestamp + static::$leeway)) { $ex = new BeforeValidException( - 'Cannot handle token with nbf prior to ' . \date(DateTime::ISO8601, (int) floor($payload->nbf)) + 'Cannot handle token with nbf prior to ' . \date(DateTime::ATOM, (int) floor($payload->nbf)) ); $ex->setPayload($payload); throw $ex; @@ -165,7 +165,7 @@ public static function decode( // correctly used the nbf claim). 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::ISO8601, (int) floor($payload->iat)) + 'Cannot handle token with iat prior to ' . \date(DateTime::ATOM, (int) floor($payload->iat)) ); $ex->setPayload($payload); throw $ex; From 953b2c88bb445b7e3bb82a5141928f13d7343afd Mon Sep 17 00:00:00 2001 From: christiandavilakoobin <46561103+christiandavilakoobin@users.noreply.github.com> Date: Wed, 16 Apr 2025 21:45:58 +0200 Subject: [PATCH 112/121] fix: validate iat and nbf on payload (#568) --- src/JWT.php | 10 ++++++++++ tests/JWTTest.php | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/JWT.php b/src/JWT.php index 37a9e0e6..5386b601 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -127,6 +127,16 @@ public static function decode( 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'); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index d09d43e3..805b867a 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -546,4 +546,31 @@ public function testAdditionalHeaderOverrides() $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'], 'secret', 'HS256'); + JWT::decode($payload, new Key('secret', 'HS256')); + } + + public function testDecodeExpectsIntegerNbf() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Payload nbf must be a number'); + + $payload = JWT::encode(['nbf' => 'not-an-int'], 'secret', 'HS256'); + JWT::decode($payload, new Key('secret', 'HS256')); + } + + public function testDecodeExpectsIntegerExp() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Payload exp must be a number'); + + $payload = JWT::encode(['exp' => 'not-an-int'], 'secret', 'HS256'); + JWT::decode($payload, new Key('secret', 'HS256')); + } } From 223d1b39dee28f2eb47c623b27a8cfe3a9945a5f Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 7 Aug 2025 13:19:49 -0700 Subject: [PATCH 113/121] chore: move release please from app to github action (#606) --- .github/release-please.yml | 3 --- .github/workflows/release-please.yml | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) delete mode 100644 .github/release-please.yml create mode 100644 .github/workflows/release-please.yml diff --git a/.github/release-please.yml b/.github/release-please.yml deleted file mode 100644 index 0a6e0cc2..00000000 --- a/.github/release-please.yml +++ /dev/null @@ -1,3 +0,0 @@ -releaseType: simple -handleGHRelease: true -primaryBranch: main diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 00000000..f2d3ca98 --- /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.GITHUB_TOKEN }} + release-type: simple From 4dbfac0260eeb0e9e643063c99998e3219cc539b Mon Sep 17 00:00:00 2001 From: Yohei Ema <24579635+meihei3@users.noreply.github.com> Date: Fri, 8 Aug 2025 05:20:40 +0900 Subject: [PATCH 114/121] feat: add SensitiveParameter attribute to security-critical parameters (#603) --- src/JWK.php | 4 ++-- src/JWT.php | 10 +++++----- src/Key.php | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/JWK.php b/src/JWK.php index 405dcc49..d5175b21 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -52,7 +52,7 @@ class JWK * * @uses parseKey */ - public static function parseKeySet(array $jwks, ?string $defaultAlg = null): array + public static function parseKeySet(#[\SensitiveParameter] array $jwks, ?string $defaultAlg = null): array { $keys = []; @@ -93,7 +93,7 @@ public static function parseKeySet(array $jwks, ?string $defaultAlg = null): arr * * @uses createPemFromModulusAndExponent */ - public static function parseKey(array $jwk, ?string $defaultAlg = null): ?Key + public static function parseKey(#[\SensitiveParameter] array $jwk, ?string $defaultAlg = null): ?Key { if (empty($jwk)) { throw new InvalidArgumentException('JWK must not be empty'); diff --git a/src/JWT.php b/src/JWT.php index 5386b601..a2e4438a 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -95,7 +95,7 @@ class JWT */ public static function decode( string $jwt, - $keyOrKeyArray, + #[\SensitiveParameter] $keyOrKeyArray, ?stdClass &$headers = null ): stdClass { // Validate JWT @@ -208,7 +208,7 @@ public static function decode( */ public static function encode( array $payload, - $key, + #[\SensitiveParameter] $key, string $alg, ?string $keyId = null, ?array $head = null @@ -246,7 +246,7 @@ public static function encode( */ public static function sign( string $msg, - $key, + #[\SensitiveParameter] $key, string $alg ): string { if (empty(static::$supported_algs[$alg])) { @@ -313,7 +313,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])) { @@ -467,7 +467,7 @@ public static function urlsafeB64Encode(string $input): string * @return Key */ private static function getKey( - $keyOrKeyArray, + #[\SensitiveParameter] $keyOrKeyArray, ?string $kid ): Key { if ($keyOrKeyArray instanceof Key) { diff --git a/src/Key.php b/src/Key.php index b34eae25..4db94851 100644 --- a/src/Key.php +++ b/src/Key.php @@ -14,7 +14,7 @@ class Key * @param string $algorithm */ public function __construct( - private $keyMaterial, + #[\SensitiveParameter] private $keyMaterial, private string $algorithm ) { if ( From f1748260d218a856b6a0c23715ac7fae1d7ca95b Mon Sep 17 00:00:00 2001 From: Luke Kuzmish <42181698+cosmastech@users.noreply.github.com> Date: Tue, 19 Aug 2025 11:14:18 -0400 Subject: [PATCH 115/121] feat: store timestamp in `ExpiredException` (#604) --- src/ExpiredException.php | 12 ++++++++++++ src/JWT.php | 1 + tests/JWTTest.php | 23 +++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/src/ExpiredException.php b/src/ExpiredException.php index 12fef094..25f44513 100644 --- a/src/ExpiredException.php +++ b/src/ExpiredException.php @@ -6,6 +6,8 @@ class ExpiredException extends \UnexpectedValueException implements JWTException { private object $payload; + private ?int $timestamp = null; + public function setPayload(object $payload): void { $this->payload = $payload; @@ -15,4 +17,14 @@ 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/JWT.php b/src/JWT.php index a2e4438a..7e08f491 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -185,6 +185,7 @@ public static function decode( if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) { $ex = new ExpiredException('Expired token'); $ex->setPayload($payload); + $ex->setTimestamp($timestamp); throw $ex; } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 805b867a..de744311 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -130,6 +130,29 @@ public function testExpiredExceptionPayload() } } + /** + * @runInSeparateProcess + */ + public function testExpiredExceptionTimestamp() + { + $this->expectException(ExpiredException::class); + + JWT::$timestamp = 98765; + $payload = [ + 'message' => 'abc', + 'exp' => 1234, + ]; + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + + try { + JWT::decode($encoded, new Key('my_key', 'HS256')); + } catch (ExpiredException $e) { + $exTimestamp = $e->getTimestamp(); + $this->assertSame(98765, $exTimestamp); + throw $e; + } + } + public function testBeforeValidExceptionPayload() { $this->expectException(BeforeValidException::class); From a3edb392bd5570e1db9ae316737c27bcaab7f78e Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 5 Sep 2025 14:25:31 -0700 Subject: [PATCH 116/121] chore: update release-please secret (#608) --- .github/workflows/release-please.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index f2d3ca98..409df545 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -15,5 +15,5 @@ jobs: - uses: googleapis/release-please-action@v4 id: release with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.YOSHI_CODE_BOT_TOKEN }} release-type: simple From 6b80341bf57838ea2d011487917337901cd71576 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 15 Dec 2025 11:45:09 -0700 Subject: [PATCH 117/121] feat: add key size validation (#613) --- src/JWT.php | 53 +++++++++- tests/JWTTest.php | 247 +++++++++++++++++++++++++++++++--------------- 2 files changed, 218 insertions(+), 82 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 7e08f491..dc564efc 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -31,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 @@ -259,11 +261,19 @@ public static function sign( 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 = ''; - if (!\is_resource($key) && !openssl_pkey_get_private($key)) { - throw new DomainException('OpenSSL unable to validate key'); + if (!\is_resource($key)) { + /** @var OpenSSLAsymmetricKey|OpenSSLCertificate|string $key */ + $key = $key; + if (!$key = openssl_pkey_get_private($key)) { + throw new DomainException('OpenSSL unable to validate key'); + } + if (str_starts_with($alg, 'RS')) { + self::validateRsaKeyLength($key); + } } $success = \openssl_sign($msg, $signature, $key, $algorithm); // @phpstan-ignore-line if (!$success) { @@ -324,6 +334,13 @@ private static function verify( list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'openssl': + if (!\is_resource($keyMaterial) && str_starts_with($algorithm, 'RS')) { + /** @var OpenSSLAsymmetricKey|OpenSSLCertificate|string $keyMaterial */ + $keyMaterial = $keyMaterial; + if ($key = openssl_pkey_get_private($keyMaterial)) { + self::validateRsaKeyLength($key); + } + } $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); // @phpstan-ignore-line if ($success === 1) { return true; @@ -361,6 +378,7 @@ private static function verify( 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); } @@ -675,4 +693,35 @@ 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(OpenSSLAsymmetricKey $key): void + { + if ($keyDetails = \openssl_pkey_get_details($key)) { + if ($keyDetails['bits'] < self::RSA_KEY_MIN_LENGTH) { + throw new DomainException('Provided key is too short'); + } + } + } } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index de744311..d64d37be 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -2,7 +2,6 @@ namespace Firebase\JWT; -use ArrayObject; use DomainException; use InvalidArgumentException; use PHPUnit\Framework\TestCase; @@ -12,18 +11,26 @@ 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() @@ -45,8 +52,9 @@ public function testExpiredToken() 'message' => 'abc', 'exp' => time() - 20, // time in the past ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); } public function testBeforeValidTokenWithNbf() @@ -56,8 +64,8 @@ public function testBeforeValidTokenWithNbf() 'message' => 'abc', 'nbf' => time() + 20, // time in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); } public function testBeforeValidTokenWithIat() @@ -67,8 +75,8 @@ public function testBeforeValidTokenWithIat() 'message' => 'abc', 'iat' => time() + 20, // time in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); } public function testValidToken() @@ -77,8 +85,8 @@ public function testValidToken() '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')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertSame($decoded->message, 'abc'); } @@ -92,8 +100,8 @@ public function testValidTokenWithLeeway() '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')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertSame($decoded->message, 'abc'); } @@ -102,14 +110,14 @@ public function testValidTokenWithLeeway() */ public function testExpiredTokenWithLeeway() { + $this->expectException(ExpiredException::class); JWT::$leeway = 60; $payload = [ 'message' => 'abc', 'exp' => time() - 70, // time far in the past ]; - $this->expectException(ExpiredException::class); - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertSame($decoded->message, 'abc'); } @@ -120,9 +128,9 @@ public function testExpiredExceptionPayload() 'message' => 'abc', 'exp' => time() - 100, // time in the past ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); try { - JWT::decode($encoded, new Key('my_key', 'HS256')); + JWT::decode($encoded, $this->hmacKey); } catch (ExpiredException $e) { $exceptionPayload = (array) $e->getPayload(); $this->assertEquals($exceptionPayload, $payload); @@ -142,10 +150,10 @@ public function testExpiredExceptionTimestamp() 'message' => 'abc', 'exp' => 1234, ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); try { - JWT::decode($encoded, new Key('my_key', 'HS256')); + JWT::decode($encoded, $this->hmacKey); } catch (ExpiredException $e) { $exTimestamp = $e->getTimestamp(); $this->assertSame(98765, $exTimestamp); @@ -160,9 +168,9 @@ public function testBeforeValidExceptionPayload() 'message' => 'abc', 'iat' => time() + 100, // time in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); try { - JWT::decode($encoded, new Key('my_key', 'HS256')); + JWT::decode($encoded, $this->hmacKey); } catch (BeforeValidException $e) { $exceptionPayload = (array) $e->getPayload(); $this->assertEquals($exceptionPayload, $payload); @@ -178,8 +186,8 @@ public function testValidTokenWithNbf() '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')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertSame($decoded->message, 'abc'); } @@ -193,8 +201,8 @@ public function testValidTokenWithNbfLeeway() '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')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertSame($decoded->message, 'abc'); } @@ -208,10 +216,10 @@ public function testInvalidTokenWithNbfLeeway() 'message' => 'abc', 'nbf' => time() + 65, // not before too far in future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(BeforeValidException::class); $this->expectExceptionMessage('Cannot handle token with nbf prior to'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + JWT::decode($encoded, $this->hmacKey); } public function testValidTokenWithNbfIgnoresIat() @@ -221,8 +229,8 @@ public function testValidTokenWithNbfIgnoresIat() 'nbf' => time() - 20, // time in the future 'iat' => time() + 20, // time in the past ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertEquals('abc', $decoded->message); } @@ -232,8 +240,8 @@ public function testValidTokenWithNbfMicrotime() 'message' => 'abc', 'nbf' => microtime(true), // use microtime ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertEquals('abc', $decoded->message); } @@ -245,8 +253,8 @@ public function testInvalidTokenWithNbfMicrotime() 'message' => 'abc', 'nbf' => microtime(true) + 20, // use microtime in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey); } /** @@ -259,8 +267,8 @@ public function testValidTokenWithIatLeeway() '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')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertSame($decoded->message, 'abc'); } @@ -274,10 +282,10 @@ public function testInvalidTokenWithIatLeeway() 'message' => 'abc', 'iat' => time() + 65, // issued too far in future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(BeforeValidException::class); $this->expectExceptionMessage('Cannot handle token with iat prior to'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + JWT::decode($encoded, $this->hmacKey); } public function testValidTokenWithIatMicrotime() @@ -286,8 +294,8 @@ public function testValidTokenWithIatMicrotime() 'message' => 'abc', 'iat' => microtime(true), // use microtime ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); $this->assertEquals('abc', $decoded->message); } @@ -299,19 +307,21 @@ public function testInvalidTokenWithIatMicrotime() 'message' => 'abc', 'iat' => microtime(true) + 20, // use microtime in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256')); + $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'); + $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() @@ -320,7 +330,7 @@ public function testNullKeyFails() 'message' => 'abc', 'exp' => time() + JWT::$leeway + 20, // time in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(TypeError::class); JWT::decode($encoded, new Key(null, 'HS256')); } @@ -331,7 +341,7 @@ public function testEmptyKeyFails() 'message' => 'abc', 'exp' => time() + JWT::$leeway + 20, // time in the future ]; - $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); $this->expectException(InvalidArgumentException::class); JWT::decode($encoded, new Key('', 'HS256')); } @@ -339,9 +349,9 @@ public function testEmptyKeyFails() public function testKIDChooser() { $keys = [ - '0' => new Key('my_key0', 'HS256'), - '1' => new Key('my_key1', 'HS256'), - '2' => new Key('my_key2', 'HS256') + '0' => $this->generateHmac256(), + '1' => $this->generateHmac256(), + '2' => $this->generateHmac256() ]; $msg = JWT::encode(['message' => 'abc'], $keys['0']->getKeyMaterial(), 'HS256', '0'); $decoded = JWT::decode($msg, $keys); @@ -352,11 +362,11 @@ public function testKIDChooser() public function testArrayAccessKIDChooser() { - $keys = new ArrayObject([ - '0' => new Key('my_key0', 'HS256'), - '1' => new Key('my_key1', 'HS256'), - '2' => new Key('my_key2', 'HS256'), - ]); + $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(); @@ -366,59 +376,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'; $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']; @@ -541,8 +554,8 @@ public function testGetHeaders() ]; $headers = new stdClass(); - $encoded = JWT::encode($payload, 'my_key', 'HS256'); - JWT::decode($encoded, new Key('my_key', 'HS256'), $headers); + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($encoded, $this->hmacKey, $headers); $this->assertEquals($headers->typ, 'JWT'); $this->assertEquals($headers->alg, 'HS256'); @@ -552,7 +565,7 @@ public function testAdditionalHeaderOverrides() { $msg = JWT::encode( ['message' => 'abc'], - 'my_key', + $this->hmacKey->getKeyMaterial(), 'HS256', 'my_key_id', [ @@ -563,7 +576,7 @@ public function testAdditionalHeaderOverrides() ] ); $headers = new stdClass(); - JWT::decode($msg, new Key('my_key', 'HS256'), $headers); + 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'); @@ -575,8 +588,8 @@ public function testDecodeExpectsIntegerIat() $this->expectException(UnexpectedValueException::class); $this->expectExceptionMessage('Payload iat must be a number'); - $payload = JWT::encode(['iat' => 'not-an-int'], 'secret', 'HS256'); - JWT::decode($payload, new Key('secret', 'HS256')); + $payload = JWT::encode(['iat' => 'not-an-int'], $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($payload, $this->hmacKey); } public function testDecodeExpectsIntegerNbf() @@ -584,8 +597,8 @@ public function testDecodeExpectsIntegerNbf() $this->expectException(UnexpectedValueException::class); $this->expectExceptionMessage('Payload nbf must be a number'); - $payload = JWT::encode(['nbf' => 'not-an-int'], 'secret', 'HS256'); - JWT::decode($payload, new Key('secret', 'HS256')); + $payload = JWT::encode(['nbf' => 'not-an-int'], $this->hmacKey->getKeyMaterial(), 'HS256'); + JWT::decode($payload, $this->hmacKey); } public function testDecodeExpectsIntegerExp() @@ -593,7 +606,81 @@ public function testDecodeExpectsIntegerExp() $this->expectException(UnexpectedValueException::class); $this->expectExceptionMessage('Payload exp must be a number'); - $payload = JWT::encode(['exp' => 'not-an-int'], 'secret', 'HS256'); - JWT::decode($payload, new Key('secret', 'HS256')); + $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'); + + $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], + ]; + } + + private function generateHmac256(): Key + { + return new Key(random_bytes(32), 'HS256'); } } From c03036fd5dbd530a95406ca3b5f6d7b24eaa3910 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 15 Dec 2025 12:26:43 -0700 Subject: [PATCH 118/121] chore(main): release 7.0.0 (#614) --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b5f6ce4..d08ecff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [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) From 81ed59ef42f33a522b5891180df4c743222b8b92 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 15 Dec 2025 16:11:17 -0700 Subject: [PATCH 119/121] Add key size validation (#612) Co-authored-by: Marius Klocke --- README.md | 2 +- src/JWT.php | 45 +++++++++++++++++++++------------------------ src/Key.php | 7 +++---- tests/JWTTest.php | 7 +++---- 4 files changed, 28 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index e45ccb80..65b6c860 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ $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 diff --git a/src/JWT.php b/src/JWT.php index dc564efc..3ead151b 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -198,7 +198,7 @@ public static function decode( * 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 @@ -239,7 +239,7 @@ 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|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. * @param string $alg Supported algorithms are 'EdDSA', 'ES384', 'ES256', 'ES256K', 'HS256', * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' * @@ -265,17 +265,13 @@ public static function sign( return \hash_hmac($algorithm, $msg, $key, true); case 'openssl': $signature = ''; - if (!\is_resource($key)) { - /** @var OpenSSLAsymmetricKey|OpenSSLCertificate|string $key */ - $key = $key; - if (!$key = openssl_pkey_get_private($key)) { - throw new DomainException('OpenSSL unable to validate key'); - } - if (str_starts_with($alg, 'RS')) { - self::validateRsaKeyLength($key); - } + if (!$key = openssl_pkey_get_private($key)) { + throw new DomainException('OpenSSL unable to validate key'); } - $success = \openssl_sign($msg, $signature, $key, $algorithm); // @phpstan-ignore-line + if (str_starts_with($alg, 'RS')) { + self::validateRsaKeyLength($key); + } + $success = \openssl_sign($msg, $signature, $key, $algorithm); if (!$success) { throw new DomainException('OpenSSL unable to sign data'); } @@ -314,7 +310,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 Ed*, ES*, 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 @@ -334,14 +330,13 @@ private static function verify( list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'openssl': - if (!\is_resource($keyMaterial) && str_starts_with($algorithm, 'RS')) { - /** @var OpenSSLAsymmetricKey|OpenSSLCertificate|string $keyMaterial */ - $keyMaterial = $keyMaterial; - if ($key = openssl_pkey_get_private($keyMaterial)) { - self::validateRsaKeyLength($key); + if (str_starts_with($algorithm, 'RS')) { + if (!$key = openssl_pkey_get_private($keyMaterial)) { + throw new DomainException('OpenSSL unable to validate key'); } + self::validateRsaKeyLength($key); } - $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); // @phpstan-ignore-line + $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); if ($success === 1) { return true; } @@ -699,6 +694,7 @@ private static function readDER(string $der, int $offset = 0): array * * @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 @@ -716,12 +712,13 @@ private static function validateHmacKeyLength(string $key, string $algorithm): v * @param OpenSSLAsymmetricKey $key RSA key material * @throws DomainException Provided key is too short */ - private static function validateRsaKeyLength(OpenSSLAsymmetricKey $key): void + private static function validateRsaKeyLength(#[\SensitiveParameter] OpenSSLAsymmetricKey $key): void { - if ($keyDetails = \openssl_pkey_get_details($key)) { - if ($keyDetails['bits'] < self::RSA_KEY_MIN_LENGTH) { - throw new DomainException('Provided key is too short'); - } + 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'); } } } diff --git a/src/Key.php b/src/Key.php index 4db94851..694d3b13 100644 --- a/src/Key.php +++ b/src/Key.php @@ -10,7 +10,7 @@ class Key { /** - * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial * @param string $algorithm */ public function __construct( @@ -21,9 +21,8 @@ public function __construct( !\is_string($keyMaterial) && !$keyMaterial instanceof OpenSSLAsymmetricKey && !$keyMaterial instanceof OpenSSLCertificate - && !\is_resource($keyMaterial) ) { - throw new TypeError('Key material must be a string, resource, or OpenSSLAsymmetricKey'); + throw new TypeError('Key material must be a string, OpenSSLCertificate, or OpenSSLAsymmetricKey'); } if (empty($keyMaterial)) { @@ -46,7 +45,7 @@ public function getAlgorithm(): string } /** - * @return string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate + * @return string|OpenSSLAsymmetricKey|OpenSSLCertificate */ public function getKeyMaterial() { diff --git a/tests/JWTTest.php b/tests/JWTTest.php index d64d37be..d920a851 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -28,7 +28,6 @@ public function testUrlSafeCharacters() public function testMalformedUtf8StringsFail() { - $this->expectException(DomainException::class); JWT::encode(['message' => pack('c', 128)], $this->hmacKey->getKeyMaterial(), 'HS256'); } @@ -531,17 +530,17 @@ public function provideEncodeDecode() ]; } - 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); } From 7044f9ae7e7d175d28cca71714feb236f1c0e252 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 16 Dec 2025 12:59:01 -0800 Subject: [PATCH 120/121] fix: add key length validation for ec keys (#615) --- src/JWT.php | 32 +++++++++++++++--- tests/JWTTest.php | 59 +++++++++++++++++++++++++++++++++ tests/data/ecdsa192-private.pem | 5 +++ tests/data/ecdsa192-public.pem | 4 +++ 4 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 tests/data/ecdsa192-private.pem create mode 100644 tests/data/ecdsa192-public.pem diff --git a/src/JWT.php b/src/JWT.php index 3ead151b..c18e4cc0 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -270,6 +270,8 @@ public static function sign( } 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) { @@ -330,11 +332,13 @@ private static function verify( list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'openssl': - if (str_starts_with($algorithm, 'RS')) { - if (!$key = openssl_pkey_get_private($keyMaterial)) { - throw new DomainException('OpenSSL unable to validate key'); - } + 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) { @@ -721,4 +725,24 @@ private static function validateRsaKeyLength(#[\SensitiveParameter] OpenSSLAsymm 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/tests/JWTTest.php b/tests/JWTTest.php index d920a851..a1dd08a4 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -678,6 +678,65 @@ public function provideHmac() ]; } + /** @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/data/ecdsa192-private.pem b/tests/data/ecdsa192-private.pem new file mode 100644 index 00000000..4d9bc0b1 --- /dev/null +++ b/tests/data/ecdsa192-private.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MF8CAQEEGPRkK7lK/9FuZ3BE8ZX+dlHavL22Q9CN2KAKBggqhkjOPQMBAaE0AzIA +BL4pM50YcLq/I9Y8T+C+fwoOtwRW8zdV6yQmG9fD8zWaAs28+UxHeK8VD7THatbp +wg== +-----END EC PRIVATE KEY----- \ No newline at end of file diff --git a/tests/data/ecdsa192-public.pem b/tests/data/ecdsa192-public.pem new file mode 100644 index 00000000..d4aeee1d --- /dev/null +++ b/tests/data/ecdsa192-public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MEkwEwYHKoZIzj0CAQYIKoZIzj0DAQEDMgAEvikznRhwur8j1jxP4L5/Cg63BFbz +N1XrJCYb18PzNZoCzbz5TEd4rxUPtMdq1unC +-----END PUBLIC KEY----- From 5645b43af647b6947daac1d0f659dd1fbe8d3b65 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 16 Dec 2025 14:17:28 -0800 Subject: [PATCH 121/121] chore(main): release 7.0.2 (#616) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d08ecff8..26376076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 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)