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 01/38] 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 02/38] 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 03/38] 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 04/38] 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 05/38] 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 06/38] 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 07/38] 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 08/38] 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 09/38] 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 10/38] 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 11/38] 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 12/38] 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 13/38] 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 14/38] 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 15/38] 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 16/38] 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 17/38] 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 18/38] 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 19/38] 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 20/38] 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 21/38] 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 22/38] 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 23/38] 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 24/38] 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 25/38] 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 26/38] 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 27/38] 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 28/38] 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 29/38] 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 30/38] 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 31/38] 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 32/38] 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 33/38] 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 34/38] 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 35/38] 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 36/38] 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 37/38] 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 38/38] 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)