From edda0f9ee45b8367699804f792a9be6d5175e816 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 30 Dec 2021 11:00:10 -0800 Subject: [PATCH 01/95] feat!: require Key object, use JSON_UNESCAPED_SLASHES, remove constants (#376) --- .gitignore | 1 + README.md | 3 +- src/JWK.php | 10 +- src/JWT.php | 94 ++++++------- tests/JWKTest.php | 38 +++-- tests/JWTTest.php | 168 +++++++++-------------- tests/autoload.php.dist | 17 --- tests/bootstrap.php | 13 +- tests/{ => data}/ecdsa-private.pem | 0 tests/{ => data}/ecdsa-public.pem | 0 tests/{ => data}/ecdsa384-private.pem | 0 tests/{ => data}/ecdsa384-public.pem | 0 tests/{ => data}/ed25519-1.pub | 0 tests/{ => data}/ed25519-1.sec | 0 tests/{ => data}/rsa-jwkset.json | 7 +- tests/{ => data}/rsa-with-passphrase.pem | 0 tests/{ => data}/rsa1-private.pem | 0 tests/{ => data}/rsa1-public.pub | 0 tests/{ => data}/rsa2-private.pem | 0 19 files changed, 161 insertions(+), 190 deletions(-) delete mode 100644 tests/autoload.php.dist rename tests/{ => data}/ecdsa-private.pem (100%) rename tests/{ => data}/ecdsa-public.pem (100%) rename tests/{ => data}/ecdsa384-private.pem (100%) rename tests/{ => data}/ecdsa384-public.pem (100%) rename tests/{ => data}/ed25519-1.pub (100%) rename tests/{ => data}/ed25519-1.sec (100%) rename tests/{ => data}/rsa-jwkset.json (86%) rename tests/{ => data}/rsa-with-passphrase.pem (100%) rename tests/{ => data}/rsa1-private.pem (100%) rename tests/{ => data}/rsa1-public.pub (100%) rename tests/{ => data}/rsa2-private.pem (100%) diff --git a/.gitignore b/.gitignore index 080f19aa..b22842cb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ phpunit.phar phpunit.phar.asc composer.phar composer.lock +.phpunit.result.cache diff --git a/README.md b/README.md index acd1720c..0a0023b2 100644 --- a/README.md +++ b/README.md @@ -200,8 +200,7 @@ $jwks = ['keys' => []]; // JWK::parseKeySet($jwks) returns an associative array of **kid** to private // key. Pass this as the second parameter to JWT::decode. -// NOTE: The deprecated $supportedAlgorithm must be supplied when parsing from JWK. -JWT::decode($payload, JWK::parseKeySet($jwks), $supportedAlgorithm); +JWT::decode($payload, JWK::parseKeySet($jwks)); ``` Changelog diff --git a/src/JWK.php b/src/JWK.php index 981a9ba7..c53251d3 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -47,7 +47,15 @@ public static function parseKeySet(array $jwks) foreach ($jwks['keys'] as $k => $v) { $kid = isset($v['kid']) ? $v['kid'] : $k; if ($key = self::parseKey($v)) { - $keys[$kid] = $key; + if (isset($v['alg'])) { + $keys[$kid] = new Key($key, $v['alg']); + } else { + // The "alg" parameter is optional in a KTY, but is required + // for parsing in this library. Add it manually to your JWK + // array if it doesn't already exist. + // @see https://datatracker.ietf.org/doc/html/rfc7517#section-4.4 + throw new InvalidArgumentException('JWK key is missing "alg"'); + } } } diff --git a/src/JWT.php b/src/JWT.php index b2e78041..e6038648 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -25,9 +25,12 @@ */ class JWT { - const ASN1_INTEGER = 0x02; - const ASN1_SEQUENCE = 0x10; - const ASN1_BIT_STRING = 0x03; + // const ASN1_INTEGER = 0x02; + // const ASN1_SEQUENCE = 0x10; + // const ASN1_BIT_STRING = 0x03; + private static $asn1Integer = 0x02; + private static $asn1Sequence = 0x10; + private static $asn1BitString = 0x03; /** * When checking nbf, iat or expiration times, @@ -60,13 +63,11 @@ class JWT * Decodes a JWT string into a PHP object. * * @param string $jwt The JWT - * @param Key|array|mixed $keyOrKeyArray The Key or array of Key objects. + * @param Key|array $keyOrKeyArray The Key or array of Key objects. * If the algorithm used is asymmetric, this is the public key * Each Key object contains an algorithm and matching key. * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', * 'HS512', 'RS256', 'RS384', and 'RS512' - * @param array $allowed_algs [DEPRECATED] List of supported verification algorithms. Only - * should be used for backwards compatibility. * * @return object The JWT's payload as a PHP object * @@ -81,8 +82,9 @@ class JWT * @uses jsonDecode * @uses urlsafeB64Decode */ - public static function decode($jwt, $keyOrKeyArray, array $allowed_algs = array()) + public static function decode($jwt, $keyOrKeyArray) { + // Validate JWT $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; if (empty($keyOrKeyArray)) { @@ -109,31 +111,18 @@ public static function decode($jwt, $keyOrKeyArray, array $allowed_algs = array( throw new UnexpectedValueException('Algorithm not supported'); } - list($keyMaterial, $algorithm) = self::getKeyMaterialAndAlgorithm( - $keyOrKeyArray, - empty($header->kid) ? null : $header->kid - ); + $key = self::getKey($keyOrKeyArray, empty($header->kid) ? null : $header->kid); - if (empty($algorithm)) { - // Use deprecated "allowed_algs" to determine if the algorithm is supported. - // This opens up the possibility of an attack in some implementations. - // @see https://github.com/firebase/php-jwt/issues/351 - if (!\in_array($header->alg, $allowed_algs)) { - throw new UnexpectedValueException('Algorithm not allowed'); - } - } else { - // Check the algorithm - if (!self::constantTimeEquals($algorithm, $header->alg)) { - // See issue #351 - throw new UnexpectedValueException('Incorrect key for this algorithm'); - } + // Check the algorithm + if (!self::constantTimeEquals($key->getAlgorithm(), $header->alg)) { + // See issue #351 + throw new UnexpectedValueException('Incorrect key for this algorithm'); } if ($header->alg === 'ES256' || $header->alg === 'ES384') { // OpenSSL expects an ASN.1 DER sequence for ES256/ES384 signatures $sig = self::signatureToDER($sig); } - - if (!static::verify("$headb64.$bodyb64", $sig, $keyMaterial, $header->alg)) { + if (!static::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) { throw new SignatureInvalidException('Signature verification failed'); } @@ -179,7 +168,7 @@ public static function decode($jwt, $keyOrKeyArray, array $allowed_algs = array( * @uses jsonEncode * @uses urlsafeB64Encode */ - public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $head = null) + public static function encode($payload, $key, $alg, $keyId = null, $head = null) { $header = array('typ' => 'JWT', 'alg' => $alg); if ($keyId !== null) { @@ -212,7 +201,7 @@ public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $he * * @throws DomainException Unsupported algorithm or bad key was specified */ - public static function sign($msg, $key, $alg = 'HS256') + public static function sign($msg, $key, $alg) { if (empty(static::$supported_algs[$alg])) { throw new DomainException('Algorithm not supported'); @@ -345,7 +334,12 @@ public static function jsonDecode($input) */ public static function jsonEncode($input) { - $json = \json_encode($input); + if (PHP_VERSION_ID >= 50400) { + $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); + } else { + // PHP 5.3 only + $json = \json_encode($input); + } if ($errno = \json_last_error()) { static::handleJsonError($errno); } elseif ($json === 'null' && $input !== null) { @@ -394,21 +388,21 @@ public static function urlsafeB64Encode($input) * * @return array containing the keyMaterial and algorithm */ - private static function getKeyMaterialAndAlgorithm($keyOrKeyArray, $kid = null) + private static function getKey($keyOrKeyArray, $kid = null) { - if ( - is_string($keyOrKeyArray) - || is_resource($keyOrKeyArray) - || $keyOrKeyArray instanceof OpenSSLAsymmetricKey - ) { - return array($keyOrKeyArray, null); - } - if ($keyOrKeyArray instanceof Key) { - return array($keyOrKeyArray->getKeyMaterial(), $keyOrKeyArray->getAlgorithm()); + return $keyOrKeyArray; } if (is_array($keyOrKeyArray) || $keyOrKeyArray instanceof ArrayAccess) { + foreach ($keyOrKeyArray as $keyId => $key) { + if (!$key instanceof Key) { + throw new UnexpectedValueException( + '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an ' + . 'array of Firebase\JWT\Key keys' + ); + } + } if (!isset($kid)) { throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); } @@ -416,18 +410,12 @@ private static function getKeyMaterialAndAlgorithm($keyOrKeyArray, $kid = null) throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); } - $key = $keyOrKeyArray[$kid]; - - if ($key instanceof Key) { - return array($key->getKeyMaterial(), $key->getAlgorithm()); - } - - return array($key, null); + return $keyOrKeyArray[$kid]; } throw new UnexpectedValueException( - '$keyOrKeyArray must be a string|resource key, an array of string|resource keys, ' - . 'an instance of Firebase\JWT\Key key or an array of Firebase\JWT\Key keys' + '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an ' + . 'array of Firebase\JWT\Key keys' ); } @@ -515,9 +503,9 @@ private static function signatureToDER($sig) } return self::encodeDER( - self::ASN1_SEQUENCE, - self::encodeDER(self::ASN1_INTEGER, $r) . - self::encodeDER(self::ASN1_INTEGER, $s) + self::$asn1Sequence, + self::encodeDER(self::$asn1Integer, $r) . + self::encodeDER(self::$asn1Integer, $s) ); } @@ -531,7 +519,7 @@ private static function signatureToDER($sig) private static function encodeDER($type, $value) { $tag_header = 0; - if ($type === self::ASN1_SEQUENCE) { + if ($type === self::$asn1Sequence) { $tag_header |= 0x20; } @@ -596,7 +584,7 @@ private static function readDER($der, $offset = 0) } // Value - if ($type == self::ASN1_BIT_STRING) { + if ($type == self::$asn1BitString) { $pos++; // Skip the first contents octet (padding indicator) $data = \substr($der, $pos, $len - 1); $pos += $len - 1; diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 0709836d..b908ea64 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -38,26 +38,42 @@ public function testParsePrivateKey() 'UnexpectedValueException', 'RSA private keys are not supported' ); - + $jwkSet = json_decode( - file_get_contents(__DIR__ . '/rsa-jwkset.json'), + file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), true ); $jwkSet['keys'][0]['d'] = 'privatekeyvalue'; - + + JWK::parseKeySet($jwkSet); + } + + public function testParsePrivateKeyWithoutAlg() + { + $this->setExpectedException( + 'InvalidArgumentException', + 'JWK key is missing "alg"' + ); + + $jwkSet = json_decode( + file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), + true + ); + unset($jwkSet['keys'][0]['alg']); + JWK::parseKeySet($jwkSet); } - + public function testParseKeyWithEmptyDValue() { $jwkSet = json_decode( - file_get_contents(__DIR__ . '/rsa-jwkset.json'), + file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), true ); - + // empty or null values are ok $jwkSet['keys'][0]['d'] = null; - + $keys = JWK::parseKeySet($jwkSet); $this->assertTrue(is_array($keys)); } @@ -65,7 +81,7 @@ public function testParseKeyWithEmptyDValue() public function testParseJwkKeySet() { $jwkSet = json_decode( - file_get_contents(__DIR__ . '/rsa-jwkset.json'), + file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), true ); $keys = JWK::parseKeySet($jwkSet); @@ -93,7 +109,7 @@ public function testParseJwkKeySet_empty() */ public function testDecodeByJwkKeySetTokenExpired() { - $privKey1 = file_get_contents(__DIR__ . '/rsa1-private.pem'); + $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); $payload = array('exp' => strtotime('-1 hour')); $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); @@ -107,7 +123,7 @@ public function testDecodeByJwkKeySetTokenExpired() */ public function testDecodeByJwkKeySet() { - $privKey1 = file_get_contents(__DIR__ . '/rsa1-private.pem'); + $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); $payload = array('sub' => 'foo', 'exp' => strtotime('+10 seconds')); $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); @@ -121,7 +137,7 @@ public function testDecodeByJwkKeySet() */ public function testDecodeByMultiJwkKeySet() { - $privKey2 = file_get_contents(__DIR__ . '/rsa2-private.pem'); + $privKey2 = file_get_contents(__DIR__ . '/data/rsa2-private.pem'); $payload = array('sub' => 'bar', 'exp' => strtotime('+10 seconds')); $msg = JWT::encode($payload, $privKey2, 'RS256', 'jwk2'); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 1c81c1ed..36e2095e 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -23,21 +23,21 @@ public function testDecodeFromPython() { $msg = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.Iio6aHR0cDovL2FwcGxpY2F0aW9uL2NsaWNreT9ibGFoPTEuMjMmZi5vbz00NTYgQUMwMDAgMTIzIg.E_U8X2YpMT5K1cEiT_3-IvBYfrdIFIeVYeOqre_Z5Cg'; $this->assertEquals( - JWT::decode($msg, 'my_key', array('HS256')), + JWT::decode($msg, new Key('my_key', 'HS256')), '*:http://application/clicky?blah=1.23&f.oo=456 AC000 123' ); } public function testUrlSafeCharacters() { - $encoded = JWT::encode('f?', 'a'); - $this->assertEquals('f?', JWT::decode($encoded, 'a', array('HS256'))); + $encoded = JWT::encode('f?', 'a', 'HS256'); + $this->assertEquals('f?', JWT::decode($encoded, new Key('a', 'HS256'))); } public function testMalformedUtf8StringsFail() { $this->setExpectedException('DomainException'); - JWT::encode(pack('c', 128), 'a'); + JWT::encode(pack('c', 128), 'a', 'HS256'); } public function testMalformedJsonThrowsException() @@ -52,8 +52,8 @@ public function testExpiredToken() $payload = array( "message" => "abc", "exp" => time() - 20); // time in the past - $encoded = JWT::encode($payload, 'my_key'); - JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + JWT::decode($encoded, new Key('my_key', 'HS256')); } public function testBeforeValidTokenWithNbf() @@ -62,8 +62,8 @@ public function testBeforeValidTokenWithNbf() $payload = array( "message" => "abc", "nbf" => time() + 20); // time in the future - $encoded = JWT::encode($payload, 'my_key'); - JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + JWT::decode($encoded, new Key('my_key', 'HS256')); } public function testBeforeValidTokenWithIat() @@ -72,8 +72,8 @@ public function testBeforeValidTokenWithIat() $payload = array( "message" => "abc", "iat" => time() + 20); // time in the future - $encoded = JWT::encode($payload, 'my_key'); - JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + JWT::decode($encoded, new Key('my_key', 'HS256')); } public function testValidToken() @@ -81,8 +81,8 @@ public function testValidToken() $payload = array( "message" => "abc", "exp" => time() + JWT::$leeway + 20); // time in the future - $encoded = JWT::encode($payload, 'my_key'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); } @@ -92,8 +92,8 @@ public function testValidTokenWithLeeway() $payload = array( "message" => "abc", "exp" => time() - 20); // time in the past - $encoded = JWT::encode($payload, 'my_key'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); JWT::$leeway = 0; } @@ -105,22 +105,12 @@ public function testExpiredTokenWithLeeway() "message" => "abc", "exp" => time() - 70); // time far in the past $this->setExpectedException('Firebase\JWT\ExpiredException'); - $encoded = JWT::encode($payload, 'my_key'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); JWT::$leeway = 0; } - public function testValidTokenWithList() - { - $payload = array( - "message" => "abc", - "exp" => time() + 20); // time in the future - $encoded = JWT::encode($payload, 'my_key'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256', 'HS512')); - $this->assertEquals($decoded->message, 'abc'); - } - public function testValidTokenWithNbf() { $payload = array( @@ -128,8 +118,8 @@ public function testValidTokenWithNbf() "iat" => time(), "exp" => time() + 20, // time in the future "nbf" => time() - 20); - $encoded = JWT::encode($payload, 'my_key'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); } @@ -139,8 +129,8 @@ public function testValidTokenWithNbfLeeway() $payload = array( "message" => "abc", "nbf" => time() + 20); // not before in near (leeway) future - $encoded = JWT::encode($payload, 'my_key'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); JWT::$leeway = 0; } @@ -151,9 +141,9 @@ public function testInvalidTokenWithNbfLeeway() $payload = array( "message" => "abc", "nbf" => time() + 65); // not before too far in future - $encoded = JWT::encode($payload, 'my_key'); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->setExpectedException('Firebase\JWT\BeforeValidException'); - JWT::decode($encoded, 'my_key', array('HS256')); + JWT::decode($encoded, new Key('my_key', 'HS256')); JWT::$leeway = 0; } @@ -163,8 +153,8 @@ public function testValidTokenWithIatLeeway() $payload = array( "message" => "abc", "iat" => time() + 20); // issued in near (leeway) future - $encoded = JWT::encode($payload, 'my_key'); - $decoded = JWT::decode($encoded, 'my_key', array('HS256')); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); JWT::$leeway = 0; } @@ -175,9 +165,9 @@ public function testInvalidTokenWithIatLeeway() $payload = array( "message" => "abc", "iat" => time() + 65); // issued too far in future - $encoded = JWT::encode($payload, 'my_key'); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->setExpectedException('Firebase\JWT\BeforeValidException'); - JWT::decode($encoded, 'my_key', array('HS256')); + JWT::decode($encoded, new Key('my_key', 'HS256')); JWT::$leeway = 0; } @@ -186,9 +176,9 @@ public function testInvalidToken() $payload = array( "message" => "abc", "exp" => time() + 20); // time in the future - $encoded = JWT::encode($payload, 'my_key'); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->setExpectedException('Firebase\JWT\SignatureInvalidException'); - JWT::decode($encoded, 'my_key2', array('HS256')); + JWT::decode($encoded, new Key('my_key2', 'HS256')); } public function testNullKeyFails() @@ -196,9 +186,9 @@ public function testNullKeyFails() $payload = array( "message" => "abc", "exp" => time() + JWT::$leeway + 20); // time in the future - $encoded = JWT::encode($payload, 'my_key'); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->setExpectedException('InvalidArgumentException'); - JWT::decode($encoded, null, array('HS256')); + JWT::decode($encoded, new Key(null, 'HS256')); } public function testEmptyKeyFails() @@ -206,71 +196,77 @@ public function testEmptyKeyFails() $payload = array( "message" => "abc", "exp" => time() + JWT::$leeway + 20); // time in the future - $encoded = JWT::encode($payload, 'my_key'); + $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->setExpectedException('InvalidArgumentException'); - JWT::decode($encoded, '', array('HS256')); + JWT::decode($encoded, new Key('', 'HS256')); } public function testKIDChooser() { - $keys = array('1' => 'my_key', '2' => 'my_key2'); - $msg = JWT::encode('abc', $keys['1'], 'HS256', '1'); - $decoded = JWT::decode($msg, $keys, array('HS256')); + $keys = array( + '1' => new Key('my_key', 'HS256'), + '2' => new Key('my_key2', 'HS256') + ); + $msg = JWT::encode('abc', $keys['1']->getKeyMaterial(), 'HS256', '1'); + $decoded = JWT::decode($msg, $keys); $this->assertEquals($decoded, 'abc'); } public function testArrayAccessKIDChooser() { - $keys = new ArrayObject(array('1' => 'my_key', '2' => 'my_key2')); - $msg = JWT::encode('abc', $keys['1'], 'HS256', '1'); - $decoded = JWT::decode($msg, $keys, array('HS256')); + $keys = new ArrayObject(array( + '1' => new Key('my_key', 'HS256'), + '2' => new Key('my_key2', 'HS256'), + )); + $msg = JWT::encode('abc', $keys['1']->getKeyMaterial(), 'HS256', '1'); + $decoded = JWT::decode($msg, $keys); $this->assertEquals($decoded, 'abc'); } public function testNoneAlgorithm() { - $msg = JWT::encode('abc', 'my_key'); + $msg = JWT::encode('abc', 'my_key', 'HS256'); $this->setExpectedException('UnexpectedValueException'); - JWT::decode($msg, 'my_key', array('none')); + JWT::decode($msg, new Key('my_key', 'none')); } public function testIncorrectAlgorithm() { - $msg = JWT::encode('abc', 'my_key'); + $msg = JWT::encode('abc', 'my_key', 'HS256'); $this->setExpectedException('UnexpectedValueException'); - JWT::decode($msg, 'my_key', array('RS256')); + JWT::decode($msg, new Key('my_key', 'RS256')); } - public function testMissingAlgorithm() + public function testEmptyAlgorithm() { - $msg = JWT::encode('abc', 'my_key'); + $msg = JWT::encode('abc', 'my_key', 'HS256'); $this->setExpectedException('UnexpectedValueException'); - JWT::decode($msg, 'my_key'); + JWT::decode($msg, new Key('my_key', '')); } public function testAdditionalHeaders() { $msg = JWT::encode('abc', 'my_key', 'HS256', null, array('cty' => 'test-eit;v=1')); - $this->assertEquals(JWT::decode($msg, 'my_key', array('HS256')), 'abc'); + $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), 'abc'); } public function testInvalidSegmentCount() { $this->setExpectedException('UnexpectedValueException'); - JWT::decode('brokenheader.brokenbody', 'my_key', array('HS256')); + JWT::decode('brokenheader.brokenbody', new Key('my_key', 'HS256')); } public function testInvalidSignatureEncoding() { $msg = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6ImZvbyJ9.Q4Kee9E8o0Xfo4ADXvYA8t7dN_X_bU9K5w6tXuiSjlUxx"; $this->setExpectedException('UnexpectedValueException'); - JWT::decode($msg, 'secret', array('HS256')); + JWT::decode($msg, new Key('secret', 'HS256')); } public function testHSEncodeDecode() { - $msg = JWT::encode('abc', 'my_key'); - $this->assertEquals(JWT::decode($msg, 'my_key', array('HS256')), 'abc'); + $msg = JWT::encode('abc', 'my_key', 'HS256'); + $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), 'abc'); } public function testRSEncodeDecode() @@ -281,7 +277,7 @@ public function testRSEncodeDecode() $msg = JWT::encode('abc', $privKey, 'RS256'); $pubKey = openssl_pkey_get_details($privKey); $pubKey = $pubKey['key']; - $decoded = JWT::decode($msg, $pubKey, array('RS256')); + $decoded = JWT::decode($msg, new Key($pubKey, 'RS256')); $this->assertEquals($decoded, 'abc'); } @@ -294,7 +290,7 @@ public function testEdDsaEncodeDecode() $msg = JWT::encode($payload, $privKey, 'EdDSA'); $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); - $decoded = JWT::decode($msg, $pubKey, array('EdDSA')); + $decoded = JWT::decode($msg, new Key($pubKey, 'EdDSA')); $this->assertEquals('bar', $decoded->foo); } @@ -310,20 +306,20 @@ public function testInvalidEdDsaEncodeDecode() $keyPair = sodium_crypto_sign_keypair(); $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); $this->setExpectedException('Firebase\JWT\SignatureInvalidException'); - JWT::decode($msg, $pubKey, array('EdDSA')); + JWT::decode($msg, new Key($pubKey, 'EdDSA')); } public function testRSEncodeDecodeWithPassphrase() { $privateKey = openssl_pkey_get_private( - file_get_contents(__DIR__ . '/rsa-with-passphrase.pem'), + file_get_contents(__DIR__ . '/data/rsa-with-passphrase.pem'), 'passphrase' ); $jwt = JWT::encode('abc', $privateKey, 'RS256'); $keyDetails = openssl_pkey_get_details($privateKey); $pubKey = $keyDetails['key']; - $decoded = JWT::decode($jwt, $pubKey, array('RS256')); + $decoded = JWT::decode($jwt, new Key($pubKey, 'RS256')); $this->assertEquals($decoded, 'abc'); } @@ -337,23 +333,6 @@ public function testEncodeDecode($privateKeyFile, $publicKeyFile, $alg) $payload = array('foo' => 'bar'); $encoded = JWT::encode($payload, $privateKey, $alg); - // Verify decoding succeeds - $publicKey = file_get_contents($publicKeyFile); - $decoded = JWT::decode($encoded, $publicKey, array($alg)); - - $this->assertEquals('bar', $decoded->foo); - } - - /** - * @runInSeparateProcess - * @dataProvider provideEncodeDecode - */ - public function testEncodeDecodeWithKeyObject($privateKeyFile, $publicKeyFile, $alg) - { - $privateKey = file_get_contents($privateKeyFile); - $payload = array('foo' => 'bar'); - $encoded = JWT::encode($payload, $privateKey, $alg); - // Verify decoding succeeds $publicKey = file_get_contents($publicKeyFile); $decoded = JWT::decode($encoded, new Key($publicKey, $alg)); @@ -361,38 +340,27 @@ public function testEncodeDecodeWithKeyObject($privateKeyFile, $publicKeyFile, $ $this->assertEquals('bar', $decoded->foo); } - public function testArrayAccessKIDChooserWithKeyObject() - { - $keys = new ArrayObject(array( - '1' => new Key('my_key', 'HS256'), - '2' => new Key('my_key2', 'HS256'), - )); - $msg = JWT::encode('abc', $keys['1']->getKeyMaterial(), 'HS256', '1'); - $decoded = JWT::decode($msg, $keys); - $this->assertEquals($decoded, 'abc'); - } - public function provideEncodeDecode() { return array( - array(__DIR__ . '/ecdsa-private.pem', __DIR__ . '/ecdsa-public.pem', 'ES256'), - array(__DIR__ . '/ecdsa384-private.pem', __DIR__ . '/ecdsa384-public.pem', 'ES384'), - array(__DIR__ . '/rsa1-private.pem', __DIR__ . '/rsa1-public.pub', 'RS512'), - array(__DIR__ . '/ed25519-1.sec', __DIR__ . '/ed25519-1.pub', 'EdDSA'), + array(__DIR__ . '/data/ecdsa-private.pem', __DIR__ . '/data/ecdsa-public.pem', 'ES256'), + array(__DIR__ . '/data/ecdsa384-private.pem', __DIR__ . '/data/ecdsa384-public.pem', 'ES384'), + array(__DIR__ . '/data/rsa1-private.pem', __DIR__ . '/data/rsa1-public.pub', 'RS512'), + array(__DIR__ . '/data/ed25519-1.sec', __DIR__ . '/data/ed25519-1.pub', 'EdDSA'), ); } public function testEncodeDecodeWithResource() { - $pem = file_get_contents(__DIR__ . '/rsa1-public.pub'); + $pem = file_get_contents(__DIR__ . '/data/rsa1-public.pub'); $resource = openssl_pkey_get_public($pem); - $privateKey = file_get_contents(__DIR__ . '/rsa1-private.pem'); + $privateKey = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); $payload = array('foo' => 'bar'); $encoded = JWT::encode($payload, $privateKey, 'RS512'); // Verify decoding succeeds - $decoded = JWT::decode($encoded, $resource, array('RS512')); + $decoded = JWT::decode($encoded, new Key($resource, 'RS512')); $this->assertEquals('bar', $decoded->foo); } diff --git a/tests/autoload.php.dist b/tests/autoload.php.dist deleted file mode 100644 index 2e4310a0..00000000 --- a/tests/autoload.php.dist +++ /dev/null @@ -1,17 +0,0 @@ - Date: Mon, 24 Jan 2022 06:32:49 -0800 Subject: [PATCH 02/95] chore: update changelog for v6.0.0 (#391) --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 0a0023b2..26e0436b 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,14 @@ JWT::decode($payload, JWK::parseKeySet($jwks)); Changelog --------- +#### 6.0.0 / 2022-01-24 + + - New Key object to prevent key/algorithm type confusion (#365) + - Add JWK support (#273) + - Add ES256 support (#256) + - Add ES384 support (#324) + - Add Ed25519 support (#343) + #### 5.0.0 / 2017-06-26 - Support RS384 and RS512. See [#117](https://github.com/firebase/php-jwt/pull/117). Thanks [@joostfaassen](https://github.com/joostfaassen)! From 0541cba75ab108ef901985e68055a92646c73534 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 24 Jan 2022 07:18:34 -0800 Subject: [PATCH 03/95] feat!: update return type for JWK methods (#392) --- README.md | 5 +++-- src/JWK.php | 22 ++++++++++------------ src/JWT.php | 6 +++--- tests/JWKTest.php | 6 +++--- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 26e0436b..7839af60 100644 --- a/README.md +++ b/README.md @@ -198,8 +198,8 @@ use Firebase\JWT\JWT; // this endpoint: https://www.gstatic.com/iap/verify/public_key-jwk $jwks = ['keys' => []]; -// JWK::parseKeySet($jwks) returns an associative array of **kid** to private -// key. Pass this as the second parameter to JWT::decode. +// JWK::parseKeySet($jwks) returns an associative array of **kid** to Firebase\JWT\Key +// objects. Pass this as the second parameter to JWT::decode. JWT::decode($payload, JWK::parseKeySet($jwks)); ``` @@ -208,6 +208,7 @@ Changelog #### 6.0.0 / 2022-01-24 + - **Backwards-Compatibility Breaking Changes**: See the [Release Notes](https://github.com/firebase/php-jwt/releases/tag/v5.5.1) for more information. - New Key object to prevent key/algorithm type confusion (#365) - Add JWK support (#273) - Add ES256 support (#256) diff --git a/src/JWK.php b/src/JWK.php index c53251d3..c5506548 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -25,7 +25,7 @@ class JWK * * @param array $jwks The JSON Web Key Set as an associative array * - * @return array An associative array that represents the set of keys + * @return array An associative array of key IDs (kid) to Key objects * * @throws InvalidArgumentException Provided JWK Set is empty * @throws UnexpectedValueException Provided JWK Set was invalid @@ -47,15 +47,7 @@ public static function parseKeySet(array $jwks) foreach ($jwks['keys'] as $k => $v) { $kid = isset($v['kid']) ? $v['kid'] : $k; if ($key = self::parseKey($v)) { - if (isset($v['alg'])) { - $keys[$kid] = new Key($key, $v['alg']); - } else { - // The "alg" parameter is optional in a KTY, but is required - // for parsing in this library. Add it manually to your JWK - // array if it doesn't already exist. - // @see https://datatracker.ietf.org/doc/html/rfc7517#section-4.4 - throw new InvalidArgumentException('JWK key is missing "alg"'); - } + $keys[$kid] = $key; } } @@ -71,7 +63,7 @@ public static function parseKeySet(array $jwks) * * @param array $jwk An individual JWK * - * @return resource|array An associative array that represents the key + * @return Key The key object for the JWK * * @throws InvalidArgumentException Provided JWK is empty * @throws UnexpectedValueException Provided JWK was invalid @@ -87,6 +79,12 @@ public static function parseKey(array $jwk) if (!isset($jwk['kty'])) { throw new UnexpectedValueException('JWK must contain a "kty" parameter'); } + if (!isset($jwk['alg'])) { + // The "alg" parameter is optional in a KTY, but is required for parsing in + // this library. Add it manually to your JWK array if it doesn't already exist. + // @see https://datatracker.ietf.org/doc/html/rfc7517#section-4.4 + throw new UnexpectedValueException('JWK must contain an "alg" parameter'); + } switch ($jwk['kty']) { case 'RSA': @@ -104,7 +102,7 @@ public static function parseKey(array $jwk) 'OpenSSL error: ' . \openssl_error_string() ); } - return $publicKey; + return new Key($publicKey, $jwk['alg']); default: // Currently only RSA is supported break; diff --git a/src/JWT.php b/src/JWT.php index e6038648..725a0832 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -63,7 +63,7 @@ class JWT * Decodes a JWT string into a PHP object. * * @param string $jwt The JWT - * @param Key|array $keyOrKeyArray The Key or array of Key objects. + * @param Key|array $keyOrKeyArray The Key or associative array of key IDs (kid) to Key objects. * If the algorithm used is asymmetric, this is the public key * Each Key object contains an algorithm and matching key. * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', @@ -381,8 +381,8 @@ public static function urlsafeB64Encode($input) /** * Determine if an algorithm has been provided for each Key * - * @param Key|array|mixed $keyOrKeyArray - * @param string|null $kid + * @param Key|array $keyOrKeyArray + * @param string|null $kid * * @throws UnexpectedValueException * diff --git a/tests/JWKTest.php b/tests/JWKTest.php index b908ea64..c580f40f 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -28,7 +28,7 @@ public function testInvalidAlgorithm() 'No supported algorithms found in JWK Set' ); - $badJwk = array('kty' => 'BADALG'); + $badJwk = array('kty' => 'BADALG', 'alg' => 'RSA256'); $keys = JWK::parseKeySet(array('keys' => array($badJwk))); } @@ -51,8 +51,8 @@ public function testParsePrivateKey() public function testParsePrivateKeyWithoutAlg() { $this->setExpectedException( - 'InvalidArgumentException', - 'JWK key is missing "alg"' + 'UnexpectedValueException', + 'JWK must contain an "alg" parameter' ); $jwkSet = json_decode( From abc63f3fc6c723543a0d8b0b207d62fc34f83510 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 7 Feb 2022 06:54:21 -0800 Subject: [PATCH 04/95] fix: correct order for hash_equals (#393) --- src/JWT.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 725a0832..1226102c 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -284,7 +284,7 @@ private static function verify($msg, $signature, $key, $alg) case 'hash_hmac': default: $hash = \hash_hmac($algorithm, $msg, $key, true); - return self::constantTimeEquals($signature, $hash); + return self::constantTimeEquals($hash, $signature); } } @@ -420,8 +420,8 @@ private static function getKey($keyOrKeyArray, $kid = null) } /** - * @param string $left - * @param string $right + * @param string $left The string of known length to compare against + * @param string $right The user-supplied string * @return bool */ public static function constantTimeEquals($left, $right) From 7e0a273c602f30cc947c3828e84d4ecc23202a0b Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 7 Feb 2022 07:04:10 -0800 Subject: [PATCH 05/95] fix: use property_exists for checking key id (#395) --- src/JWT.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index 1226102c..6130c59c 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -111,7 +111,7 @@ public static function decode($jwt, $keyOrKeyArray) throw new UnexpectedValueException('Algorithm not supported'); } - $key = self::getKey($keyOrKeyArray, empty($header->kid) ? null : $header->kid); + $key = self::getKey($keyOrKeyArray, property_exists($header, 'kid') ? $header->kid : null); // Check the algorithm if (!self::constantTimeEquals($key->getAlgorithm(), $header->alg)) { From 8bcbcf8f652813bb3d15839b29fa75c665a9f68f Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 7 Feb 2022 17:45:09 +0200 Subject: [PATCH 06/95] chore(docs): fix release URL in README (#394) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7839af60..2300ae30 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ Changelog #### 6.0.0 / 2022-01-24 - - **Backwards-Compatibility Breaking Changes**: See the [Release Notes](https://github.com/firebase/php-jwt/releases/tag/v5.5.1) for more information. + - **Backwards-Compatibility Breaking Changes**: See the [Release Notes](https://github.com/firebase/php-jwt/releases/tag/v6.0.0) for more information. - New Key object to prevent key/algorithm type confusion (#365) - Add JWK support (#273) - Add ES256 support (#256) From f8550f8b2848da8bf41871b8051b9c7aabb0bd91 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 16 Feb 2022 06:14:33 -0800 Subject: [PATCH 07/95] feat: add typing, require PHP 8.0 (#385) --- .github/workflows/tests.yml | 40 +------- composer.json | 4 +- src/JWK.php | 15 ++- src/JWT.php | 197 +++++++++++++++++------------------- src/Key.php | 35 +++---- tests/JWKTest.php | 40 ++++---- tests/JWTTest.php | 185 +++++++++++++++++---------------- 7 files changed, 239 insertions(+), 277 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 50d8a5f0..873eae24 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1"] + php: [ "8.0", "8.1"] name: PHP ${{matrix.php }} Unit Test steps: - uses: actions/checkout@v2 @@ -24,45 +24,9 @@ jobs: timeout_minutes: 10 max_attempts: 3 command: composer install - - if: ${{ matrix.php == '5.6' }} - run: composer require --dev --with-dependencies paragonie/sodium_compat - name: Run Script run: vendor/bin/phpunit - # use dockerfiles for old versions of php (setup-php times out for those). - test_php55: - name: "PHP 5.5 Unit Test" - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Run Unit Tests - uses: docker://php:5.5-cli - with: - entrypoint: ./.github/actions/entrypoint.sh - - test_php54: - name: "PHP 5.4 Unit Test" - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Run Unit Tests - uses: docker://php:5.4-cli - with: - entrypoint: ./.github/actions/entrypoint.sh - - test_php53: - name: "PHP 5.3 Unit Test" - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Run Unit Tests - uses: docker://tomsowerby/php-5.3:cli - with: - entrypoint: ./.github/actions/entrypoint.sh - style: runs-on: ubuntu-latest name: PHP Style Check @@ -71,7 +35,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: "7.0" + php-version: "8.0" - name: Run Script run: | composer require friendsofphp/php-cs-fixer diff --git a/composer.json b/composer.json index 6146e2dc..4e190ea3 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ ], "license": "BSD-3-Clause", "require": { - "php": ">=5.3.0" + "php": "^8.0" }, "suggest": { "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" @@ -31,6 +31,6 @@ } }, "require-dev": { - "phpunit/phpunit": ">=4.8 <=9" + "phpunit/phpunit": "^9.5" } } diff --git a/src/JWK.php b/src/JWK.php index c5506548..5663c948 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -33,13 +33,14 @@ class JWK * * @uses parseKey */ - public static function parseKeySet(array $jwks) + public static function parseKeySet(array $jwks): array { - $keys = array(); + $keys = []; if (!isset($jwks['keys'])) { throw new UnexpectedValueException('"keys" member must exist in the JWK Set'); } + if (empty($jwks['keys'])) { throw new InvalidArgumentException('JWK Set did not contain any keys'); } @@ -71,14 +72,16 @@ public static function parseKeySet(array $jwks) * * @uses createPemFromModulusAndExponent */ - public static function parseKey(array $jwk) + public static function parseKey(array $jwk): ?Key { if (empty($jwk)) { throw new InvalidArgumentException('JWK must not be empty'); } + if (!isset($jwk['kty'])) { throw new UnexpectedValueException('JWK must contain a "kty" parameter'); } + if (!isset($jwk['alg'])) { // The "alg" parameter is optional in a KTY, but is required for parsing in // this library. Add it manually to your JWK array if it doesn't already exist. @@ -107,6 +110,8 @@ public static function parseKey(array $jwk) // Currently only RSA is supported break; } + + return null; } /** @@ -124,10 +129,10 @@ private static function createPemFromModulusAndExponent($n, $e) $modulus = JWT::urlsafeB64Decode($n); $publicExponent = JWT::urlsafeB64Decode($e); - $components = array( + $components = [ 'modulus' => \pack('Ca*a*', 2, self::encodeLength(\strlen($modulus)), $modulus), 'publicExponent' => \pack('Ca*a*', 2, self::encodeLength(\strlen($publicExponent)), $publicExponent) - ); + ]; $rsaPublicKey = \pack( 'Ca*a*a*', diff --git a/src/JWT.php b/src/JWT.php index 6130c59c..f5852dcd 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -9,6 +9,7 @@ use OpenSSLAsymmetricKey; use UnexpectedValueException; use DateTime; +use stdClass; /** * JSON Web Token implementation, based on this spec: @@ -25,51 +26,40 @@ */ class JWT { - // const ASN1_INTEGER = 0x02; - // const ASN1_SEQUENCE = 0x10; - // const ASN1_BIT_STRING = 0x03; - private static $asn1Integer = 0x02; - private static $asn1Sequence = 0x10; - private static $asn1BitString = 0x03; + private const ASN1_INTEGER = 0x02; + private const ASN1_SEQUENCE = 0x10; + private const ASN1_BIT_STRING = 0x03; /** * When checking nbf, iat or expiration times, * we want to provide some extra leeway time to * account for clock skew. */ - public static $leeway = 0; - - /** - * Allow the current timestamp to be specified. - * Useful for fixing a value within unit testing. - * - * Will default to PHP time() value if null. - */ - public static $timestamp = null; - - public static $supported_algs = array( - 'ES384' => array('openssl', 'SHA384'), - 'ES256' => array('openssl', 'SHA256'), - 'HS256' => array('hash_hmac', 'SHA256'), - 'HS384' => array('hash_hmac', 'SHA384'), - 'HS512' => array('hash_hmac', 'SHA512'), - 'RS256' => array('openssl', 'SHA256'), - 'RS384' => array('openssl', 'SHA384'), - 'RS512' => array('openssl', 'SHA512'), - 'EdDSA' => array('sodium_crypto', 'EdDSA'), - ); + public static int $leeway = 0; + + public static array $supported_algs = [ + 'ES384' => ['openssl', 'SHA384'], + 'ES256' => ['openssl', 'SHA256'], + 'HS256' => ['hash_hmac', 'SHA256'], + 'HS384' => ['hash_hmac', 'SHA384'], + 'HS512' => ['hash_hmac', 'SHA512'], + 'RS256' => ['openssl', 'SHA256'], + 'RS384' => ['openssl', 'SHA384'], + 'RS512' => ['openssl', 'SHA512'], + 'EdDSA' => ['sodium_crypto', 'EdDSA'], + ]; /** * Decodes a JWT string into a PHP object. * - * @param string $jwt The JWT - * @param Key|array $keyOrKeyArray The Key or associative array of key IDs (kid) to Key objects. - * If the algorithm used is asymmetric, this is the public key - * Each Key object contains an algorithm and matching key. - * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', - * 'HS512', 'RS256', 'RS384', and 'RS512' + * @param string $jwt The JWT + * @param Key|array $keyOrKeyArray The Key or associative array of key IDs (kid) to Key objects. + * If the algorithm used is asymmetric, this is the public key + * Each Key object contains an algorithm and matching key. + * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', + * 'HS512', 'RS256', 'RS384', and 'RS512' * - * @return object The JWT's payload as a PHP object + * @return stdClass The JWT's payload as a PHP object * * @throws InvalidArgumentException Provided key/key-array was empty * @throws DomainException Provided JWT is malformed @@ -82,10 +72,10 @@ class JWT * @uses jsonDecode * @uses urlsafeB64Decode */ - public static function decode($jwt, $keyOrKeyArray) + public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray): stdClass { // Validate JWT - $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; + $timestamp = \time(); if (empty($keyOrKeyArray)) { throw new InvalidArgumentException('Key may not be empty'); @@ -101,6 +91,9 @@ public static function decode($jwt, $keyOrKeyArray) if (null === $payload = static::jsonDecode(static::urlsafeB64Decode($bodyb64))) { throw new UnexpectedValueException('Invalid claims encoding'); } + if (!$payload instanceof stdClass) { + throw new UnexpectedValueException('Payload must be a JSON object'); + } if (false === ($sig = static::urlsafeB64Decode($cryptob64))) { throw new UnexpectedValueException('Invalid signature encoding'); } @@ -154,30 +147,32 @@ public static function decode($jwt, $keyOrKeyArray) /** * Converts and signs a PHP object or array into a JWT string. * - * @param object|array $payload PHP object or array - * @param string|resource $key The secret key. - * If the algorithm used is asymmetric, this is the private key - * @param string $alg The signing algorithm. - * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', - * 'HS512', 'RS256', 'RS384', and 'RS512' - * @param mixed $keyId - * @param array $head An array with header elements to attach + * @param array $payload PHP array + * @param string|OpenSSLAsymmetricKey $key The secret key. + * If the algorithm used is asymmetric, this is the private key + * @param string $keyId + * @param array $head An array with header elements to attach * * @return string A signed JWT * * @uses jsonEncode * @uses urlsafeB64Encode */ - public static function encode($payload, $key, $alg, $keyId = null, $head = null) - { - $header = array('typ' => 'JWT', 'alg' => $alg); + public static function encode( + array $payload, + string|OpenSSLAsymmetricKey $key, + string $alg, + string $keyId = null, + array $head = null + ): string { + $header = ['typ' => 'JWT', 'alg' => $alg]; if ($keyId !== null) { $header['kid'] = $keyId; } if (isset($head) && \is_array($head)) { $header = \array_merge($head, $header); } - $segments = array(); + $segments = []; $segments[] = static::urlsafeB64Encode(static::jsonEncode($header)); $segments[] = static::urlsafeB64Encode(static::jsonEncode($payload)); $signing_input = \implode('.', $segments); @@ -191,17 +186,16 @@ public static function encode($payload, $key, $alg, $keyId = null, $head = null) /** * Sign a string with a given key and algorithm. * - * @param string $msg The message to sign - * @param string|resource $key The secret key - * @param string $alg The signing algorithm. - * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', - * 'HS512', 'RS256', 'RS384', and 'RS512' + * @param string $msg The message to sign + * @param string|OpenSSLAsymmetricKey $key The secret key. + * @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', + * 'HS512', 'RS256', 'RS384', and 'RS512' * * @return string An encrypted message * * @throws DomainException Unsupported algorithm or bad key was specified */ - public static function sign($msg, $key, $alg) + public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, string $alg): string { if (empty(static::$supported_algs[$alg])) { throw new DomainException('Algorithm not supported'); @@ -241,17 +235,21 @@ public static function sign($msg, $key, $alg) * Verify a signature with the message, key and method. Not all methods * are symmetric, so we must have a separate verify and sign method. * - * @param string $msg The original message (header and body) - * @param string $signature The original signature - * @param string|resource $key For HS*, a string key works. for RS*, must be a resource of an openssl public key - * @param string $alg The algorithm + * @param string $msg The original message (header and body) + * @param string $signature The original signature + * @param string|OpenSSLAsymmetricKey $key For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey + * @param string $alg The algorithm * * @return bool * * @throws DomainException Invalid Algorithm, bad key, or OpenSSL failure */ - private static function verify($msg, $signature, $key, $alg) - { + private static function verify( + string $msg, + string $signature, + string|OpenSSLAsymmetricKey $keyMaterial, + string $alg + ): bool { if (empty(static::$supported_algs[$alg])) { throw new DomainException('Algorithm not supported'); } @@ -259,7 +257,7 @@ private static function verify($msg, $signature, $key, $alg) list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'openssl': - $success = \openssl_verify($msg, $signature, $key, $algorithm); + $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); if ($success === 1) { return true; } elseif ($success === 0) { @@ -275,7 +273,7 @@ private static function verify($msg, $signature, $key, $alg) } try { // The last non-empty line is used as the key. - $lines = array_filter(explode("\n", $key)); + $lines = array_filter(explode("\n", $keyMaterial)); $key = base64_decode(end($lines)); return sodium_crypto_sign_verify_detached($signature, $msg, $key); } catch (Exception $e) { @@ -283,7 +281,7 @@ private static function verify($msg, $signature, $key, $alg) } case 'hash_hmac': default: - $hash = \hash_hmac($algorithm, $msg, $key, true); + $hash = \hash_hmac($algorithm, $msg, $keyMaterial, true); return self::constantTimeEquals($hash, $signature); } } @@ -293,27 +291,13 @@ private static function verify($msg, $signature, $key, $alg) * * @param string $input JSON string * - * @return object Object representation of JSON string + * @return mixed The decoded JSON string * * @throws DomainException Provided string was invalid JSON */ - public static function jsonDecode($input) + public static function jsonDecode(string $input): mixed { - if (\version_compare(PHP_VERSION, '5.4.0', '>=') && !(\defined('JSON_C_VERSION') && PHP_INT_SIZE > 4)) { - /** In PHP >=5.4.0, json_decode() accepts an options parameter, that allows you - * to specify that large ints (like Steam Transaction IDs) should be treated as - * strings, rather than the PHP default behaviour of converting them to floats. - */ - $obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING); - } else { - /** Not all servers will support that, however, so for older versions we must - * manually detect large ints in the JSON string and quote them (thus converting - *them to strings) before decoding, hence the preg_replace() call. - */ - $max_int_length = \strlen((string) PHP_INT_MAX) - 1; - $json_without_bigints = \preg_replace('/:\s*(-?\d{'.$max_int_length.',})/', ': "$1"', $input); - $obj = \json_decode($json_without_bigints); - } + $obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING); if ($errno = \json_last_error()) { static::handleJsonError($errno); @@ -324,15 +308,15 @@ public static function jsonDecode($input) } /** - * Encode a PHP object into a JSON string. + * Encode a PHP array into a JSON string. * - * @param object|array $input A PHP object or array + * @param array $input A PHP array * - * @return string JSON representation of the PHP object or array + * @return string JSON representation of the PHP array * * @throws DomainException Provided object could not be encoded to valid JSON */ - public static function jsonEncode($input) + public static function jsonEncode(array $input): string { if (PHP_VERSION_ID >= 50400) { $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); @@ -355,7 +339,7 @@ public static function jsonEncode($input) * * @return string A decoded string */ - public static function urlsafeB64Decode($input) + public static function urlsafeB64Decode(string $input): string { $remainder = \strlen($input) % 4; if ($remainder) { @@ -372,7 +356,7 @@ public static function urlsafeB64Decode($input) * * @return string The base64 encode of what you passed in */ - public static function urlsafeB64Encode($input) + public static function urlsafeB64Encode(string $input): string { return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_')); } @@ -386,9 +370,9 @@ public static function urlsafeB64Encode($input) * * @throws UnexpectedValueException * - * @return array containing the keyMaterial and algorithm + * @return Key */ - private static function getKey($keyOrKeyArray, $kid = null) + private static function getKey(Key|array|ArrayAccess $keyOrKeyArray, ?string $kid): Key { if ($keyOrKeyArray instanceof Key) { return $keyOrKeyArray; @@ -397,7 +381,7 @@ private static function getKey($keyOrKeyArray, $kid = null) if (is_array($keyOrKeyArray) || $keyOrKeyArray instanceof ArrayAccess) { foreach ($keyOrKeyArray as $keyId => $key) { if (!$key instanceof Key) { - throw new UnexpectedValueException( + throw new TypeError( '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an ' . 'array of Firebase\JWT\Key keys' ); @@ -424,7 +408,7 @@ private static function getKey($keyOrKeyArray, $kid = null) * @param string $right The user-supplied string * @return bool */ - public static function constantTimeEquals($left, $right) + public static function constantTimeEquals(string $left, string $right): bool { if (\function_exists('hash_equals')) { return \hash_equals($left, $right); @@ -445,17 +429,19 @@ public static function constantTimeEquals($left, $right) * * @param int $errno An error number from json_last_error() * + * @throws DomainException + * * @return void */ - private static function handleJsonError($errno) + private static function handleJsonError(int $errno): void { - $messages = array( + $messages = [ JSON_ERROR_DEPTH => 'Maximum stack depth exceeded', JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON', JSON_ERROR_CTRL_CHAR => 'Unexpected control character found', JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON', JSON_ERROR_UTF8 => 'Malformed UTF-8 characters' //PHP >= 5.3.3 - ); + ]; throw new DomainException( isset($messages[$errno]) ? $messages[$errno] @@ -470,7 +456,7 @@ private static function handleJsonError($errno) * * @return int */ - private static function safeStrlen($str) + private static function safeStrlen(string $str): int { if (\function_exists('mb_strlen')) { return \mb_strlen($str, '8bit'); @@ -484,7 +470,7 @@ private static function safeStrlen($str) * @param string $sig The ECDSA signature to convert * @return string The encoded DER object */ - private static function signatureToDER($sig) + private static function signatureToDER(string $sig): string { // Separate the signature into r-value and s-value list($r, $s) = \str_split($sig, (int) (\strlen($sig) / 2)); @@ -503,9 +489,9 @@ private static function signatureToDER($sig) } return self::encodeDER( - self::$asn1Sequence, - self::encodeDER(self::$asn1Integer, $r) . - self::encodeDER(self::$asn1Integer, $s) + self::ASN1_SEQUENCE, + self::encodeDER(self::ASN1_INTEGER, $r) . + self::encodeDER(self::ASN1_INTEGER, $s) ); } @@ -514,12 +500,13 @@ private static function signatureToDER($sig) * * @param int $type DER tag * @param string $value the value to encode + * * @return string the encoded object */ - private static function encodeDER($type, $value) + private static function encodeDER(int $type, string $value): string { $tag_header = 0; - if ($type === self::$asn1Sequence) { + if ($type === self::ASN1_SEQUENCE) { $tag_header |= 0x20; } @@ -537,9 +524,10 @@ private static function encodeDER($type, $value) * * @param string $der binary signature in DER format * @param int $keySize the number of bits in the key + * * @return string the signature */ - private static function signatureFromDER($der, $keySize) + private static function signatureFromDER(string $der, int $keySize): string { // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE list($offset, $_) = self::readDER($der); @@ -564,9 +552,10 @@ private static function signatureFromDER($der, $keySize) * @param string $der the binary data in DER format * @param int $offset the offset of the data stream containing the object * to decode + * * @return array [$offset, $data] the new offset and the decoded object */ - private static function readDER($der, $offset = 0) + private static function readDER(string $der, int $offset = 0): array { $pos = $offset; $size = \strlen($der); @@ -584,7 +573,7 @@ private static function readDER($der, $offset = 0) } // Value - if ($type == self::$asn1BitString) { + if ($type == self::ASN1_BIT_STRING) { $pos++; // Skip the first contents octet (padding indicator) $data = \substr($der, $pos, $len - 1); $pos += $len - 1; @@ -595,6 +584,6 @@ private static function readDER($der, $offset = 0) $data = null; } - return array($pos, $data); + return [$pos, $data]; } } diff --git a/src/Key.php b/src/Key.php index f1ede6f2..286a3143 100644 --- a/src/Key.php +++ b/src/Key.php @@ -2,41 +2,34 @@ namespace Firebase\JWT; -use InvalidArgumentException; use OpenSSLAsymmetricKey; +use TypeError; +use InvalidArgumentException; class Key { - /** @var string $algorithm */ - private $algorithm; - - /** @var string|resource|OpenSSLAsymmetricKey $keyMaterial */ - private $keyMaterial; - /** - * @param string|resource|OpenSSLAsymmetricKey $keyMaterial + * @param string|OpenSSLAsymmetricKey $keyMaterial * @param string $algorithm */ - public function __construct($keyMaterial, $algorithm) - { + public function __construct( + private string|OpenSSLAsymmetricKey $keyMaterial, + private string $algorithm + ) { if ( !is_string($keyMaterial) - && !is_resource($keyMaterial) && !$keyMaterial instanceof OpenSSLAsymmetricKey ) { - throw new InvalidArgumentException('Type error: $keyMaterial must be a string, resource, or OpenSSLAsymmetricKey'); + throw new TypeError('Key material must be a string or OpenSSLAsymmetricKey'); } if (empty($keyMaterial)) { - throw new InvalidArgumentException('Type error: $keyMaterial must not be empty'); + throw new InvalidArgumentException('Key material must not be empty'); } - if (!is_string($algorithm)|| empty($keyMaterial)) { - throw new InvalidArgumentException('Type error: $algorithm must be a string'); + if (empty($algorithm)) { + throw new InvalidArgumentException('Algorithm must not be empty'); } - - $this->keyMaterial = $keyMaterial; - $this->algorithm = $algorithm; } /** @@ -44,15 +37,15 @@ public function __construct($keyMaterial, $algorithm) * * @return string */ - public function getAlgorithm() + public function getAlgorithm(): string { return $this->algorithm; } /** - * @return string|resource|OpenSSLAsymmetricKey + * @return string|OpenSSLAsymmetricKey */ - public function getKeyMaterial() + public function getKeyMaterial(): mixed { return $this->keyMaterial; } diff --git a/tests/JWKTest.php b/tests/JWKTest.php index c580f40f..d80f6c4b 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -3,6 +3,8 @@ namespace Firebase\JWT; use PHPUnit\Framework\TestCase; +use InvalidArgumentException; +use UnexpectedValueException; class JWKTest extends TestCase { @@ -13,29 +15,29 @@ class JWKTest extends TestCase public function testMissingKty() { $this->setExpectedException( - 'UnexpectedValueException', + UnexpectedValueException::class, 'JWK must contain a "kty" parameter' ); - $badJwk = array('kid' => 'foo'); - $keys = JWK::parseKeySet(array('keys' => array($badJwk))); + $badJwk = ['kid' => 'foo']; + $keys = JWK::parseKeySet(['keys' => [$badJwk]]); } public function testInvalidAlgorithm() { $this->setExpectedException( - 'UnexpectedValueException', + UnexpectedValueException::class, 'No supported algorithms found in JWK Set' ); - $badJwk = array('kty' => 'BADALG', 'alg' => 'RSA256'); - $keys = JWK::parseKeySet(array('keys' => array($badJwk))); + $badJwk = ['kty' => 'BADTYPE', 'alg' => 'RSA256']; + $keys = JWK::parseKeySet(['keys' => [$badJwk]]); } public function testParsePrivateKey() { $this->setExpectedException( - 'UnexpectedValueException', + UnexpectedValueException::class, 'RSA private keys are not supported' ); @@ -51,7 +53,7 @@ public function testParsePrivateKey() public function testParsePrivateKeyWithoutAlg() { $this->setExpectedException( - 'UnexpectedValueException', + UnexpectedValueException::class, 'JWK must contain an "alg" parameter' ); @@ -92,16 +94,16 @@ public function testParseJwkKeySet() public function testParseJwkKey_empty() { - $this->setExpectedException('InvalidArgumentException', 'JWK must not be empty'); + $this->setExpectedException(InvalidArgumentException::class, 'JWK must not be empty'); - JWK::parseKeySet(array('keys' => array(array()))); + JWK::parseKeySet(['keys' => [[]]]); } public function testParseJwkKeySet_empty() { - $this->setExpectedException('InvalidArgumentException', 'JWK Set did not contain any keys'); + $this->setExpectedException(InvalidArgumentException::class, 'JWK Set did not contain any keys'); - JWK::parseKeySet(array('keys' => array())); + JWK::parseKeySet(['keys' => []]); } /** @@ -110,12 +112,12 @@ public function testParseJwkKeySet_empty() public function testDecodeByJwkKeySetTokenExpired() { $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); - $payload = array('exp' => strtotime('-1 hour')); + $payload = ['exp' => strtotime('-1 hour')]; $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); - $this->setExpectedException('Firebase\JWT\ExpiredException'); + $this->setExpectedException(ExpiredException::class); - JWT::decode($msg, self::$keys, array('RS256')); + JWT::decode($msg, self::$keys); } /** @@ -124,10 +126,10 @@ public function testDecodeByJwkKeySetTokenExpired() public function testDecodeByJwkKeySet() { $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); - $payload = array('sub' => 'foo', 'exp' => strtotime('+10 seconds')); + $payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')]; $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); - $result = JWT::decode($msg, self::$keys, array('RS256')); + $result = JWT::decode($msg, self::$keys); $this->assertEquals("foo", $result->sub); } @@ -138,10 +140,10 @@ public function testDecodeByJwkKeySet() public function testDecodeByMultiJwkKeySet() { $privKey2 = file_get_contents(__DIR__ . '/data/rsa2-private.pem'); - $payload = array('sub' => 'bar', 'exp' => strtotime('+10 seconds')); + $payload = ['sub' => 'bar', 'exp' => strtotime('+10 seconds')]; $msg = JWT::encode($payload, $privKey2, 'RS256', 'jwk2'); - $result = JWT::decode($msg, self::$keys, array('RS256')); + $result = JWT::decode($msg, self::$keys); $this->assertEquals("bar", $result->sub); } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 36e2095e..aa5ce140 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -4,6 +4,10 @@ use ArrayObject; use PHPUnit\Framework\TestCase; +use DomainException; +use InvalidArgumentException; +use UnexpectedValueException; +use stdClass; class JWTTest extends TestCase { @@ -19,68 +23,61 @@ public function setExpectedException($exceptionName, $message = '', $code = null } } - public function testDecodeFromPython() - { - $msg = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.Iio6aHR0cDovL2FwcGxpY2F0aW9uL2NsaWNreT9ibGFoPTEuMjMmZi5vbz00NTYgQUMwMDAgMTIzIg.E_U8X2YpMT5K1cEiT_3-IvBYfrdIFIeVYeOqre_Z5Cg'; - $this->assertEquals( - JWT::decode($msg, new Key('my_key', 'HS256')), - '*:http://application/clicky?blah=1.23&f.oo=456 AC000 123' - ); - } - public function testUrlSafeCharacters() { - $encoded = JWT::encode('f?', 'a', 'HS256'); - $this->assertEquals('f?', JWT::decode($encoded, new Key('a', 'HS256'))); + $encoded = JWT::encode(['message' => 'f?'], 'a', 'HS256'); + $expected = new stdClass(); + $expected->message = 'f?'; + $this->assertEquals($expected, JWT::decode($encoded, new Key('a', 'HS256'))); } public function testMalformedUtf8StringsFail() { - $this->setExpectedException('DomainException'); - JWT::encode(pack('c', 128), 'a', 'HS256'); + $this->setExpectedException(DomainException::class); + JWT::encode(['message' => pack('c', 128)], 'a', 'HS256'); } public function testMalformedJsonThrowsException() { - $this->setExpectedException('DomainException'); + $this->setExpectedException(DomainException::class); JWT::jsonDecode('this is not valid JSON string'); } public function testExpiredToken() { - $this->setExpectedException('Firebase\JWT\ExpiredException'); - $payload = array( + $this->setExpectedException(ExpiredException::class); + $payload = [ "message" => "abc", - "exp" => time() - 20); // time in the past + "exp" => time() - 20]; // time in the past $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); } public function testBeforeValidTokenWithNbf() { - $this->setExpectedException('Firebase\JWT\BeforeValidException'); - $payload = array( + $this->setExpectedException(BeforeValidException::class); + $payload = [ "message" => "abc", - "nbf" => time() + 20); // time in the future + "nbf" => time() + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); } public function testBeforeValidTokenWithIat() { - $this->setExpectedException('Firebase\JWT\BeforeValidException'); - $payload = array( + $this->setExpectedException(BeforeValidException::class); + $payload = [ "message" => "abc", - "iat" => time() + 20); // time in the future + "iat" => time() + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); } public function testValidToken() { - $payload = array( + $payload = [ "message" => "abc", - "exp" => time() + JWT::$leeway + 20); // time in the future + "exp" => time() + JWT::$leeway + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -89,9 +86,9 @@ public function testValidToken() public function testValidTokenWithLeeway() { JWT::$leeway = 60; - $payload = array( + $payload = [ "message" => "abc", - "exp" => time() - 20); // time in the past + "exp" => time() - 20]; // time in the past $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -101,10 +98,10 @@ public function testValidTokenWithLeeway() public function testExpiredTokenWithLeeway() { JWT::$leeway = 60; - $payload = array( + $payload = [ "message" => "abc", - "exp" => time() - 70); // time far in the past - $this->setExpectedException('Firebase\JWT\ExpiredException'); + "exp" => time() - 70]; // time far in the past + $this->setExpectedException(ExpiredException::class); $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -113,11 +110,11 @@ public function testExpiredTokenWithLeeway() public function testValidTokenWithNbf() { - $payload = array( + $payload = [ "message" => "abc", "iat" => time(), "exp" => time() + 20, // time in the future - "nbf" => time() - 20); + "nbf" => time() - 20]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -126,9 +123,9 @@ public function testValidTokenWithNbf() public function testValidTokenWithNbfLeeway() { JWT::$leeway = 60; - $payload = array( + $payload = [ "message" => "abc", - "nbf" => time() + 20); // not before in near (leeway) future + "nbf" => time() + 20]; // not before in near (leeway) future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -138,11 +135,11 @@ public function testValidTokenWithNbfLeeway() public function testInvalidTokenWithNbfLeeway() { JWT::$leeway = 60; - $payload = array( + $payload = [ "message" => "abc", - "nbf" => time() + 65); // not before too far in future + "nbf" => time() + 65]; // not before too far in future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException('Firebase\JWT\BeforeValidException'); + $this->setExpectedException(BeforeValidException::class); JWT::decode($encoded, new Key('my_key', 'HS256')); JWT::$leeway = 0; } @@ -150,9 +147,9 @@ public function testInvalidTokenWithNbfLeeway() public function testValidTokenWithIatLeeway() { JWT::$leeway = 60; - $payload = array( + $payload = [ "message" => "abc", - "iat" => time() + 20); // issued in near (leeway) future + "iat" => time() + 20]; // issued in near (leeway) future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -162,123 +159,133 @@ public function testValidTokenWithIatLeeway() public function testInvalidTokenWithIatLeeway() { JWT::$leeway = 60; - $payload = array( + $payload = [ "message" => "abc", - "iat" => time() + 65); // issued too far in future + "iat" => time() + 65]; // issued too far in future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException('Firebase\JWT\BeforeValidException'); + $this->setExpectedException(BeforeValidException::class); JWT::decode($encoded, new Key('my_key', 'HS256')); JWT::$leeway = 0; } public function testInvalidToken() { - $payload = array( + $payload = [ "message" => "abc", - "exp" => time() + 20); // time in the future + "exp" => time() + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException('Firebase\JWT\SignatureInvalidException'); + $this->setExpectedException(SignatureInvalidException::class); JWT::decode($encoded, new Key('my_key2', 'HS256')); } public function testNullKeyFails() { - $payload = array( + $payload = [ "message" => "abc", - "exp" => time() + JWT::$leeway + 20); // time in the future + "exp" => time() + JWT::$leeway + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException('InvalidArgumentException'); + $this->setExpectedException('TypeError'); JWT::decode($encoded, new Key(null, 'HS256')); } public function testEmptyKeyFails() { - $payload = array( + $payload = [ "message" => "abc", - "exp" => time() + JWT::$leeway + 20); // time in the future + "exp" => time() + JWT::$leeway + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException('InvalidArgumentException'); + $this->setExpectedException(InvalidArgumentException::class); JWT::decode($encoded, new Key('', 'HS256')); } public function testKIDChooser() { - $keys = array( + $keys = [ '1' => new Key('my_key', 'HS256'), '2' => new Key('my_key2', 'HS256') - ); - $msg = JWT::encode('abc', $keys['1']->getKeyMaterial(), 'HS256', '1'); + ]; + $msg = JWT::encode(['message' => 'abc'], $keys['1']->getKeyMaterial(), 'HS256', '1'); $decoded = JWT::decode($msg, $keys); - $this->assertEquals($decoded, 'abc'); + $expected = new stdClass(); + $expected->message = 'abc'; + $this->assertEquals($decoded, $expected); } public function testArrayAccessKIDChooser() { - $keys = new ArrayObject(array( + $keys = new ArrayObject([ '1' => new Key('my_key', 'HS256'), '2' => new Key('my_key2', 'HS256'), - )); - $msg = JWT::encode('abc', $keys['1']->getKeyMaterial(), 'HS256', '1'); + ]); + $msg = JWT::encode(['message' => 'abc'], $keys['1']->getKeyMaterial(), 'HS256', '1'); $decoded = JWT::decode($msg, $keys); - $this->assertEquals($decoded, 'abc'); + $expected = new stdClass(); + $expected->message = 'abc'; + $this->assertEquals($decoded, $expected); } public function testNoneAlgorithm() { - $msg = JWT::encode('abc', 'my_key', 'HS256'); - $this->setExpectedException('UnexpectedValueException'); + $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $this->setExpectedException(UnexpectedValueException::class); JWT::decode($msg, new Key('my_key', 'none')); } public function testIncorrectAlgorithm() { - $msg = JWT::encode('abc', 'my_key', 'HS256'); - $this->setExpectedException('UnexpectedValueException'); + $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $this->setExpectedException(UnexpectedValueException::class); JWT::decode($msg, new Key('my_key', 'RS256')); } public function testEmptyAlgorithm() { - $msg = JWT::encode('abc', 'my_key', 'HS256'); - $this->setExpectedException('UnexpectedValueException'); + $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $this->setExpectedException(InvalidArgumentException::class); JWT::decode($msg, new Key('my_key', '')); } public function testAdditionalHeaders() { - $msg = JWT::encode('abc', 'my_key', 'HS256', null, array('cty' => 'test-eit;v=1')); - $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), 'abc'); + $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256', null, ['cty' => 'test-eit;v=1']); + $expected = new stdClass(); + $expected->message = 'abc'; + $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), $expected); } public function testInvalidSegmentCount() { - $this->setExpectedException('UnexpectedValueException'); + $this->setExpectedException(UnexpectedValueException::class); JWT::decode('brokenheader.brokenbody', new Key('my_key', 'HS256')); } public function testInvalidSignatureEncoding() { $msg = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6ImZvbyJ9.Q4Kee9E8o0Xfo4ADXvYA8t7dN_X_bU9K5w6tXuiSjlUxx"; - $this->setExpectedException('UnexpectedValueException'); + $this->setExpectedException(UnexpectedValueException::class); JWT::decode($msg, new Key('secret', 'HS256')); } public function testHSEncodeDecode() { - $msg = JWT::encode('abc', 'my_key', 'HS256'); - $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), 'abc'); + $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); + $expected = new stdClass(); + $expected->message = 'abc'; + $this->assertEquals(JWT::decode($msg, new Key('my_key', 'HS256')), $expected); } public function testRSEncodeDecode() { - $privKey = openssl_pkey_new(array('digest_alg' => 'sha256', + $privKey = openssl_pkey_new(['digest_alg' => 'sha256', 'private_key_bits' => 1024, - 'private_key_type' => OPENSSL_KEYTYPE_RSA)); - $msg = JWT::encode('abc', $privKey, 'RS256'); + 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + $msg = JWT::encode(['message' => 'abc'], $privKey, 'RS256'); $pubKey = openssl_pkey_get_details($privKey); $pubKey = $pubKey['key']; $decoded = JWT::decode($msg, new Key($pubKey, 'RS256')); - $this->assertEquals($decoded, 'abc'); + $expected = new stdClass(); + $expected->message = 'abc'; + $this->assertEquals($decoded, $expected); } public function testEdDsaEncodeDecode() @@ -286,7 +293,7 @@ public function testEdDsaEncodeDecode() $keyPair = sodium_crypto_sign_keypair(); $privKey = base64_encode(sodium_crypto_sign_secretkey($keyPair)); - $payload = array('foo' => 'bar'); + $payload = ['foo' => 'bar']; $msg = JWT::encode($payload, $privKey, 'EdDSA'); $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); @@ -299,13 +306,13 @@ public function testInvalidEdDsaEncodeDecode() $keyPair = sodium_crypto_sign_keypair(); $privKey = base64_encode(sodium_crypto_sign_secretkey($keyPair)); - $payload = array('foo' => 'bar'); + $payload = ['foo' => 'bar']; $msg = JWT::encode($payload, $privKey, 'EdDSA'); // Generate a different key. $keyPair = sodium_crypto_sign_keypair(); $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); - $this->setExpectedException('Firebase\JWT\SignatureInvalidException'); + $this->setExpectedException(SignatureInvalidException::class); JWT::decode($msg, new Key($pubKey, 'EdDSA')); } @@ -316,11 +323,13 @@ public function testRSEncodeDecodeWithPassphrase() 'passphrase' ); - $jwt = JWT::encode('abc', $privateKey, 'RS256'); + $jwt = JWT::encode(['message' => 'abc'], $privateKey, 'RS256'); $keyDetails = openssl_pkey_get_details($privateKey); $pubKey = $keyDetails['key']; $decoded = JWT::decode($jwt, new Key($pubKey, 'RS256')); - $this->assertEquals($decoded, 'abc'); + $expected = new stdClass(); + $expected->message = 'abc'; + $this->assertEquals($decoded, $expected); } /** @@ -330,7 +339,7 @@ public function testRSEncodeDecodeWithPassphrase() public function testEncodeDecode($privateKeyFile, $publicKeyFile, $alg) { $privateKey = file_get_contents($privateKeyFile); - $payload = array('foo' => 'bar'); + $payload = ['foo' => 'bar']; $encoded = JWT::encode($payload, $privateKey, $alg); // Verify decoding succeeds @@ -342,12 +351,12 @@ public function testEncodeDecode($privateKeyFile, $publicKeyFile, $alg) public function provideEncodeDecode() { - return array( - array(__DIR__ . '/data/ecdsa-private.pem', __DIR__ . '/data/ecdsa-public.pem', 'ES256'), - array(__DIR__ . '/data/ecdsa384-private.pem', __DIR__ . '/data/ecdsa384-public.pem', 'ES384'), - array(__DIR__ . '/data/rsa1-private.pem', __DIR__ . '/data/rsa1-public.pub', 'RS512'), - array(__DIR__ . '/data/ed25519-1.sec', __DIR__ . '/data/ed25519-1.pub', 'EdDSA'), - ); + return [ + [__DIR__ . '/data/ecdsa-private.pem', __DIR__ . '/data/ecdsa-public.pem', 'ES256'], + [__DIR__ . '/data/ecdsa384-private.pem', __DIR__ . '/data/ecdsa384-public.pem', 'ES384'], + [__DIR__ . '/data/rsa1-private.pem', __DIR__ . '/data/rsa1-public.pub', 'RS512'], + [__DIR__ . '/data/ed25519-1.sec', __DIR__ . '/data/ed25519-1.pub', 'EdDSA'], + ]; } public function testEncodeDecodeWithResource() @@ -356,7 +365,7 @@ public function testEncodeDecodeWithResource() $resource = openssl_pkey_get_public($pem); $privateKey = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); - $payload = array('foo' => 'bar'); + $payload = ['foo' => 'bar']; $encoded = JWT::encode($payload, $privateKey, 'RS512'); // Verify decoding succeeds From 958938bd9925372b79b04e42e2012bb08033e2fe Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 16 Feb 2022 06:33:52 -0800 Subject: [PATCH 08/95] chore: remove phpunit shim (#401) --- tests/JWKTest.php | 47 +++++++++++++---------------------------------- tests/JWTTest.php | 47 ++++++++++++++++++----------------------------- 2 files changed, 31 insertions(+), 63 deletions(-) diff --git a/tests/JWKTest.php b/tests/JWKTest.php index d80f6c4b..17dd4a62 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -14,10 +14,8 @@ class JWKTest extends TestCase public function testMissingKty() { - $this->setExpectedException( - UnexpectedValueException::class, - 'JWK must contain a "kty" parameter' - ); + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('JWK must contain a "kty" parameter'); $badJwk = ['kid' => 'foo']; $keys = JWK::parseKeySet(['keys' => [$badJwk]]); @@ -25,10 +23,8 @@ public function testMissingKty() public function testInvalidAlgorithm() { - $this->setExpectedException( - UnexpectedValueException::class, - 'No supported algorithms found in JWK Set' - ); + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('No supported algorithms found in JWK Set'); $badJwk = ['kty' => 'BADTYPE', 'alg' => 'RSA256']; $keys = JWK::parseKeySet(['keys' => [$badJwk]]); @@ -36,10 +32,8 @@ public function testInvalidAlgorithm() public function testParsePrivateKey() { - $this->setExpectedException( - UnexpectedValueException::class, - 'RSA private keys are not supported' - ); + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('RSA private keys are not supported'); $jwkSet = json_decode( file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), @@ -52,10 +46,8 @@ public function testParsePrivateKey() public function testParsePrivateKeyWithoutAlg() { - $this->setExpectedException( - UnexpectedValueException::class, - 'JWK must contain an "alg" parameter' - ); + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('JWK must contain an "alg" parameter'); $jwkSet = json_decode( file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), @@ -94,14 +86,16 @@ public function testParseJwkKeySet() public function testParseJwkKey_empty() { - $this->setExpectedException(InvalidArgumentException::class, 'JWK must not be empty'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('JWK must not be empty'); JWK::parseKeySet(['keys' => [[]]]); } public function testParseJwkKeySet_empty() { - $this->setExpectedException(InvalidArgumentException::class, 'JWK Set did not contain any keys'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('JWK Set did not contain any keys'); JWK::parseKeySet(['keys' => []]); } @@ -115,7 +109,7 @@ public function testDecodeByJwkKeySetTokenExpired() $payload = ['exp' => strtotime('-1 hour')]; $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); - $this->setExpectedException(ExpiredException::class); + $this->expectException(ExpiredException::class); JWT::decode($msg, self::$keys); } @@ -147,19 +141,4 @@ public function testDecodeByMultiJwkKeySet() $this->assertEquals("bar", $result->sub); } - - /* - * For compatibility with PHPUnit 4.8 and PHP < 5.6 - */ - public function setExpectedException($exceptionName, $message = '', $code = null) - { - if (method_exists($this, 'expectException')) { - $this->expectException($exceptionName); - if ($message) { - $this->expectExceptionMessage($message); - } - } else { - parent::setExpectedException($exceptionName, $message, $code); - } - } } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index aa5ce140..8b23ad6b 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -6,23 +6,12 @@ use PHPUnit\Framework\TestCase; use DomainException; use InvalidArgumentException; +use TypeError; use UnexpectedValueException; use stdClass; class JWTTest extends TestCase { - /* - * For compatibility with PHPUnit 4.8 and PHP < 5.6 - */ - public function setExpectedException($exceptionName, $message = '', $code = null) - { - if (method_exists($this, 'expectException')) { - $this->expectException($exceptionName); - } else { - parent::setExpectedException($exceptionName, $message, $code); - } - } - public function testUrlSafeCharacters() { $encoded = JWT::encode(['message' => 'f?'], 'a', 'HS256'); @@ -33,19 +22,19 @@ public function testUrlSafeCharacters() public function testMalformedUtf8StringsFail() { - $this->setExpectedException(DomainException::class); + $this->expectException(DomainException::class); JWT::encode(['message' => pack('c', 128)], 'a', 'HS256'); } public function testMalformedJsonThrowsException() { - $this->setExpectedException(DomainException::class); + $this->expectException(DomainException::class); JWT::jsonDecode('this is not valid JSON string'); } public function testExpiredToken() { - $this->setExpectedException(ExpiredException::class); + $this->expectException(ExpiredException::class); $payload = [ "message" => "abc", "exp" => time() - 20]; // time in the past @@ -55,7 +44,7 @@ public function testExpiredToken() public function testBeforeValidTokenWithNbf() { - $this->setExpectedException(BeforeValidException::class); + $this->expectException(BeforeValidException::class); $payload = [ "message" => "abc", "nbf" => time() + 20]; // time in the future @@ -65,7 +54,7 @@ public function testBeforeValidTokenWithNbf() public function testBeforeValidTokenWithIat() { - $this->setExpectedException(BeforeValidException::class); + $this->expectException(BeforeValidException::class); $payload = [ "message" => "abc", "iat" => time() + 20]; // time in the future @@ -101,7 +90,7 @@ public function testExpiredTokenWithLeeway() $payload = [ "message" => "abc", "exp" => time() - 70]; // time far in the past - $this->setExpectedException(ExpiredException::class); + $this->expectException(ExpiredException::class); $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -139,7 +128,7 @@ public function testInvalidTokenWithNbfLeeway() "message" => "abc", "nbf" => time() + 65]; // not before too far in future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException(BeforeValidException::class); + $this->expectException(BeforeValidException::class); JWT::decode($encoded, new Key('my_key', 'HS256')); JWT::$leeway = 0; } @@ -163,7 +152,7 @@ public function testInvalidTokenWithIatLeeway() "message" => "abc", "iat" => time() + 65]; // issued too far in future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException(BeforeValidException::class); + $this->expectException(BeforeValidException::class); JWT::decode($encoded, new Key('my_key', 'HS256')); JWT::$leeway = 0; } @@ -174,7 +163,7 @@ public function testInvalidToken() "message" => "abc", "exp" => time() + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException(SignatureInvalidException::class); + $this->expectException(SignatureInvalidException::class); JWT::decode($encoded, new Key('my_key2', 'HS256')); } @@ -184,7 +173,7 @@ public function testNullKeyFails() "message" => "abc", "exp" => time() + JWT::$leeway + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException('TypeError'); + $this->expectException(TypeError::class); JWT::decode($encoded, new Key(null, 'HS256')); } @@ -194,7 +183,7 @@ public function testEmptyKeyFails() "message" => "abc", "exp" => time() + JWT::$leeway + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); - $this->setExpectedException(InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); JWT::decode($encoded, new Key('', 'HS256')); } @@ -227,21 +216,21 @@ public function testArrayAccessKIDChooser() public function testNoneAlgorithm() { $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); - $this->setExpectedException(UnexpectedValueException::class); + $this->expectException(UnexpectedValueException::class); JWT::decode($msg, new Key('my_key', 'none')); } public function testIncorrectAlgorithm() { $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); - $this->setExpectedException(UnexpectedValueException::class); + $this->expectException(UnexpectedValueException::class); JWT::decode($msg, new Key('my_key', 'RS256')); } public function testEmptyAlgorithm() { $msg = JWT::encode(['message' => 'abc'], 'my_key', 'HS256'); - $this->setExpectedException(InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); JWT::decode($msg, new Key('my_key', '')); } @@ -255,14 +244,14 @@ public function testAdditionalHeaders() public function testInvalidSegmentCount() { - $this->setExpectedException(UnexpectedValueException::class); + $this->expectException(UnexpectedValueException::class); JWT::decode('brokenheader.brokenbody', new Key('my_key', 'HS256')); } public function testInvalidSignatureEncoding() { $msg = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6ImZvbyJ9.Q4Kee9E8o0Xfo4ADXvYA8t7dN_X_bU9K5w6tXuiSjlUxx"; - $this->setExpectedException(UnexpectedValueException::class); + $this->expectException(UnexpectedValueException::class); JWT::decode($msg, new Key('secret', 'HS256')); } @@ -312,7 +301,7 @@ public function testInvalidEdDsaEncodeDecode() // Generate a different key. $keyPair = sodium_crypto_sign_keypair(); $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); - $this->setExpectedException(SignatureInvalidException::class); + $this->expectException(SignatureInvalidException::class); JWT::decode($msg, new Key($pubKey, 'EdDSA')); } From e41b22aff584489f1faf79d290b1f41b52950578 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 16 Feb 2022 10:44:52 -0800 Subject: [PATCH 09/95] chore: add array casting to README (#402) --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 2300ae30..d489c69f 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,22 @@ $jwks = ['keys' => []]; JWT::decode($payload, JWK::parseKeySet($jwks)); ``` +Miscellaneous +------------- + +#### Casting to array + +The return value of `JWT::decode` is the generic PHP object `stdClass`. If you'd like to handle with arrays +instead, you can do the following: + +```php +// return type is stdClass +$decoded = JWT::decode($payload, $keys); + +// cast to array +$decoded = json_decode(json_encode($decoded), true); +``` + Changelog --------- From 13082f8ed314b8380e3596648a3a1f5d502e5b07 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 17 Feb 2022 06:42:40 -0800 Subject: [PATCH 10/95] chore: add phpstan for static analysis (#406) --- .github/workflows/tests.yml | 14 ++++++++++++++ src/JWT.php | 3 +++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 873eae24..80063a00 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,3 +41,17 @@ jobs: composer require friendsofphp/php-cs-fixer vendor/bin/php-cs-fixer fix --diff --dry-run . vendor/bin/php-cs-fixer fix --rules=native_function_invocation --allow-risky=yes --diff src + + staticanalysis: + runs-on: ubuntu-latest + name: PHPStan Static Analysis + steps: + - uses: actions/checkout@v2 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.0' + - name: Run Script + run: | + composer global require phpstan/phpstan + ~/.composer/vendor/bin/phpstan analyse src diff --git a/src/JWT.php b/src/JWT.php index f5852dcd..b725aae4 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -7,6 +7,7 @@ use Exception; use InvalidArgumentException; use OpenSSLAsymmetricKey; +use TypeError; use UnexpectedValueException; use DateTime; use stdClass; @@ -229,6 +230,8 @@ public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, strin throw new DomainException($e->getMessage(), 0, $e); } } + + throw new DomainException('Algorithm not supported'); } /** From 92aa12d73dfbf4ee157cf4d01c5342869dcd9d56 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 23 Feb 2022 07:10:42 -0800 Subject: [PATCH 11/95] chore: update library to phpstan level 7 (#407) --- .github/workflows/tests.yml | 2 +- phpstan.neon.dist | 5 ++ src/JWK.php | 20 ++++-- src/JWT.php | 126 ++++++++++++++++++++---------------- src/Key.php | 16 ++--- 5 files changed, 96 insertions(+), 73 deletions(-) create mode 100644 phpstan.neon.dist diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 80063a00..81630645 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,4 +54,4 @@ jobs: - name: Run Script run: | composer global require phpstan/phpstan - ~/.composer/vendor/bin/phpstan analyse src + ~/.composer/vendor/bin/phpstan analyse diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 00000000..56aeebfb --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,5 @@ +parameters: + level: 7 + paths: + - src + treatPhpDocTypesAsCertain: false diff --git a/src/JWK.php b/src/JWK.php index 5663c948..d5ad93a8 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -23,7 +23,7 @@ class JWK /** * Parse a set of JWK keys * - * @param array $jwks The JSON Web Key Set as an associative array + * @param array $jwks The JSON Web Key Set as an associative array * * @return array An associative array of key IDs (kid) to Key objects * @@ -48,7 +48,7 @@ public static function parseKeySet(array $jwks): array foreach ($jwks['keys'] as $k => $v) { $kid = isset($v['kid']) ? $v['kid'] : $k; if ($key = self::parseKey($v)) { - $keys[$kid] = $key; + $keys[(string) $kid] = $key; } } @@ -62,7 +62,7 @@ public static function parseKeySet(array $jwks): array /** * Parse a JWK key * - * @param array $jwk An individual JWK + * @param array $jwk An individual JWK * * @return Key The key object for the JWK * @@ -124,10 +124,16 @@ public static function parseKey(array $jwk): ?Key * * @uses encodeLength */ - private static function createPemFromModulusAndExponent($n, $e) - { - $modulus = JWT::urlsafeB64Decode($n); - $publicExponent = JWT::urlsafeB64Decode($e); + private static function createPemFromModulusAndExponent( + string $n, + string $e + ): string { + if (false === ($modulus = JWT::urlsafeB64Decode($n))) { + throw new UnexpectedValueException('Invalid JWK encoding'); + } + if (false === ($publicExponent = JWT::urlsafeB64Decode($e))) { + throw new UnexpectedValueException('Invalid header encoding'); + } $components = [ 'modulus' => \pack('Ca*a*', 2, self::encodeLength(\strlen($modulus)), $modulus), diff --git a/src/JWT.php b/src/JWT.php index b725aae4..cf58fd2a 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -7,6 +7,7 @@ use Exception; use InvalidArgumentException; use OpenSSLAsymmetricKey; +use OpenSSLCertificate; use TypeError; use UnexpectedValueException; use DateTime; @@ -38,6 +39,9 @@ class JWT */ public static int $leeway = 0; + /** + * @var array + */ public static array $supported_algs = [ 'ES384' => ['openssl', 'SHA384'], 'ES256' => ['openssl', 'SHA256'], @@ -86,10 +90,16 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray) throw new UnexpectedValueException('Wrong number of segments'); } list($headb64, $bodyb64, $cryptob64) = $tks; - if (null === ($header = static::jsonDecode(static::urlsafeB64Decode($headb64)))) { + if (false === ($headerRaw = static::urlsafeB64Decode($headb64))) { + throw new UnexpectedValueException('Invalid header encoding'); + } + if (null === ($header = static::jsonDecode($headerRaw))) { throw new UnexpectedValueException('Invalid header encoding'); } - if (null === $payload = static::jsonDecode(static::urlsafeB64Decode($bodyb64))) { + if (false === ($payloadRaw = static::urlsafeB64Decode($bodyb64))) { + throw new UnexpectedValueException('Invalid claims encoding'); + } + if (null === ($payload = static::jsonDecode($payloadRaw))) { throw new UnexpectedValueException('Invalid claims encoding'); } if (!$payload instanceof stdClass) { @@ -116,7 +126,7 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray) // OpenSSL expects an ASN.1 DER sequence for ES256/ES384 signatures $sig = self::signatureToDER($sig); } - if (!static::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) { + if (!self::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) { throw new SignatureInvalidException('Signature verification failed'); } @@ -148,11 +158,10 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray) /** * Converts and signs a PHP object or array into a JWT string. * - * @param array $payload PHP array - * @param string|OpenSSLAsymmetricKey $key The secret key. - * If the algorithm used is asymmetric, this is the private key - * @param string $keyId - * @param array $head An array with header elements to attach + * @param array $payload PHP array + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key The secret key. + * @param string $keyId + * @param array $head An array with header elements to attach * * @return string A signed JWT * @@ -161,7 +170,7 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray) */ public static function encode( array $payload, - string|OpenSSLAsymmetricKey $key, + string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key, string $alg, string $keyId = null, array $head = null @@ -174,8 +183,8 @@ public static function encode( $header = \array_merge($head, $header); } $segments = []; - $segments[] = static::urlsafeB64Encode(static::jsonEncode($header)); - $segments[] = static::urlsafeB64Encode(static::jsonEncode($payload)); + $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($header)); + $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($payload)); $signing_input = \implode('.', $segments); $signature = static::sign($signing_input, $key, $alg); @@ -187,23 +196,29 @@ public static function encode( /** * Sign a string with a given key and algorithm. * - * @param string $msg The message to sign - * @param string|OpenSSLAsymmetricKey $key The secret key. - * @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', - * 'HS512', 'RS256', 'RS384', and 'RS512' + * @param string $msg The message to sign + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key The secret key. + * @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', + * 'HS512', 'RS256', 'RS384', and 'RS512' * * @return string An encrypted message * * @throws DomainException Unsupported algorithm or bad key was specified */ - public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, string $alg): string - { + public static function sign( + string $msg, + string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key, + string $alg + ): string { if (empty(static::$supported_algs[$alg])) { throw new DomainException('Algorithm not supported'); } list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'hash_hmac': + if (!is_string($key)) { + throw new InvalidArgumentException('key must be a string when using hmac'); + } return \hash_hmac($algorithm, $msg, $key, true); case 'openssl': $signature = ''; @@ -221,10 +236,13 @@ public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, strin if (!function_exists('sodium_crypto_sign_detached')) { throw new DomainException('libsodium is not available'); } + if (!is_string($key)) { + throw new InvalidArgumentException('key must be a string when using EdDSA'); + } try { // The last non-empty line is used as the key. $lines = array_filter(explode("\n", $key)); - $key = base64_decode(end($lines)); + $key = base64_decode((string) end($lines)); return sodium_crypto_sign_detached($msg, $key); } catch (Exception $e) { throw new DomainException($e->getMessage(), 0, $e); @@ -238,10 +256,10 @@ public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, strin * Verify a signature with the message, key and method. Not all methods * are symmetric, so we must have a separate verify and sign method. * - * @param string $msg The original message (header and body) - * @param string $signature The original signature - * @param string|OpenSSLAsymmetricKey $key For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey - * @param string $alg The algorithm + * @param string $msg The original message (header and body) + * @param string $signature The original signature + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey + * @param string $alg The algorithm * * @return bool * @@ -250,7 +268,7 @@ public static function sign(string $msg, string|OpenSSLAsymmetricKey $key, strin private static function verify( string $msg, string $signature, - string|OpenSSLAsymmetricKey $keyMaterial, + string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial, string $alg ): bool { if (empty(static::$supported_algs[$alg])) { @@ -274,16 +292,22 @@ private static function verify( if (!function_exists('sodium_crypto_sign_verify_detached')) { throw new DomainException('libsodium is not available'); } + if (!is_string($keyMaterial)) { + throw new InvalidArgumentException('key must be a string when using EdDSA'); + } try { // The last non-empty line is used as the key. $lines = array_filter(explode("\n", $keyMaterial)); - $key = base64_decode(end($lines)); + $key = base64_decode((string) end($lines)); return sodium_crypto_sign_verify_detached($signature, $msg, $key); } catch (Exception $e) { throw new DomainException($e->getMessage(), 0, $e); } case 'hash_hmac': default: + if (!is_string($keyMaterial)) { + throw new InvalidArgumentException('key must be a string when using hmac'); + } $hash = \hash_hmac($algorithm, $msg, $keyMaterial, true); return self::constantTimeEquals($hash, $signature); } @@ -303,7 +327,7 @@ public static function jsonDecode(string $input): mixed $obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING); if ($errno = \json_last_error()) { - static::handleJsonError($errno); + self::handleJsonError($errno); } elseif ($obj === null && $input !== 'null') { throw new DomainException('Null result with non-null input'); } @@ -313,13 +337,13 @@ public static function jsonDecode(string $input): mixed /** * Encode a PHP array into a JSON string. * - * @param array $input A PHP array + * @param array $input A PHP array * - * @return string JSON representation of the PHP array + * @return string|false JSON representation of the PHP array * * @throws DomainException Provided object could not be encoded to valid JSON */ - public static function jsonEncode(array $input): string + public static function jsonEncode(array $input): string|false { if (PHP_VERSION_ID >= 50400) { $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); @@ -328,7 +352,7 @@ public static function jsonEncode(array $input): string $json = \json_encode($input); } if ($errno = \json_last_error()) { - static::handleJsonError($errno); + self::handleJsonError($errno); } elseif ($json === 'null' && $input !== null) { throw new DomainException('Null result with non-null input'); } @@ -342,7 +366,7 @@ public static function jsonEncode(array $input): string * * @return string A decoded string */ - public static function urlsafeB64Decode(string $input): string + public static function urlsafeB64Decode(string $input): string|false { $remainder = \strlen($input) % 4; if ($remainder) { @@ -381,29 +405,22 @@ private static function getKey(Key|array|ArrayAccess $keyOrKeyArray, ?string $ki return $keyOrKeyArray; } - if (is_array($keyOrKeyArray) || $keyOrKeyArray instanceof ArrayAccess) { - foreach ($keyOrKeyArray as $keyId => $key) { - if (!$key instanceof Key) { - throw new TypeError( - '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an ' - . 'array of Firebase\JWT\Key keys' - ); - } - } - if (!isset($kid)) { - throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); - } - if (!isset($keyOrKeyArray[$kid])) { - throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); + foreach ($keyOrKeyArray as $keyId => $key) { + if (!$key instanceof Key) { + throw new TypeError( + '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an ' + . 'array of Firebase\JWT\Key keys' + ); } - - return $keyOrKeyArray[$kid]; + } + if (!isset($kid)) { + throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); + } + if (!isset($keyOrKeyArray[$kid])) { + throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); } - throw new UnexpectedValueException( - '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an ' - . 'array of Firebase\JWT\Key keys' - ); + return $keyOrKeyArray[$kid]; } /** @@ -416,13 +433,13 @@ public static function constantTimeEquals(string $left, string $right): bool if (\function_exists('hash_equals')) { return \hash_equals($left, $right); } - $len = \min(static::safeStrlen($left), static::safeStrlen($right)); + $len = \min(self::safeStrlen($left), self::safeStrlen($right)); $status = 0; for ($i = 0; $i < $len; $i++) { $status |= (\ord($left[$i]) ^ \ord($right[$i])); } - $status |= (static::safeStrlen($left) ^ static::safeStrlen($right)); + $status |= (self::safeStrlen($left) ^ self::safeStrlen($right)); return ($status === 0); } @@ -476,7 +493,8 @@ private static function safeStrlen(string $str): int private static function signatureToDER(string $sig): string { // Separate the signature into r-value and s-value - list($r, $s) = \str_split($sig, (int) (\strlen($sig) / 2)); + $length = max(1, (int) (\strlen($sig) / 2)); + list($r, $s) = \str_split($sig, $length > 0 ? $length : 1); // Trim leading zeros $r = \ltrim($r, "\x00"); @@ -556,7 +574,7 @@ private static function signatureFromDER(string $der, int $keySize): string * @param int $offset the offset of the data stream containing the object * to decode * - * @return array [$offset, $data] the new offset and the decoded object + * @return array{int, string|null} the new offset and the decoded object */ private static function readDER(string $der, int $offset = 0): array { diff --git a/src/Key.php b/src/Key.php index 286a3143..2f648dec 100644 --- a/src/Key.php +++ b/src/Key.php @@ -3,26 +3,20 @@ namespace Firebase\JWT; use OpenSSLAsymmetricKey; +use OpenSSLCertificate; use TypeError; use InvalidArgumentException; class Key { /** - * @param string|OpenSSLAsymmetricKey $keyMaterial + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial * @param string $algorithm */ public function __construct( - private string|OpenSSLAsymmetricKey $keyMaterial, + private string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial, private string $algorithm ) { - if ( - !is_string($keyMaterial) - && !$keyMaterial instanceof OpenSSLAsymmetricKey - ) { - throw new TypeError('Key material must be a string or OpenSSLAsymmetricKey'); - } - if (empty($keyMaterial)) { throw new InvalidArgumentException('Key material must not be empty'); } @@ -43,9 +37,9 @@ public function getAlgorithm(): string } /** - * @return string|OpenSSLAsymmetricKey + * @return string|OpenSSLAsymmetricKey|OpenSSLCertificate|array */ - public function getKeyMaterial(): mixed + public function getKeyMaterial(): string|OpenSSLAsymmetricKey|OpenSSLCertificate|array { return $this->keyMaterial; } From 52943f51c5caea1f6665f769f047a4e6e7fc6951 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Sun, 20 Mar 2022 15:59:35 -0700 Subject: [PATCH 12/95] feat: add back compatibility for >= PHP 7.1 (#405) --- .github/workflows/tests.yml | 2 +- composer.json | 4 +-- src/JWK.php | 22 +++++--------- src/JWT.php | 59 ++++++++++++++++++++----------------- src/Key.php | 28 ++++++++++++++---- 5 files changed, 66 insertions(+), 49 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 81630645..68d4f10b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ "8.0", "8.1"] + php: [ "7.1", "7.2", "7.3", "7.4", "8.0", "8.1"] name: PHP ${{matrix.php }} Unit Test steps: - uses: actions/checkout@v2 diff --git a/composer.json b/composer.json index 4e190ea3..5ef2ea2d 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ ], "license": "BSD-3-Clause", "require": { - "php": "^8.0" + "php": "^7.1||^8.0" }, "suggest": { "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" @@ -31,6 +31,6 @@ } }, "require-dev": { - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^7.5||9.5" } } diff --git a/src/JWK.php b/src/JWK.php index d5ad93a8..dbc446e6 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -128,24 +128,18 @@ private static function createPemFromModulusAndExponent( string $n, string $e ): string { - if (false === ($modulus = JWT::urlsafeB64Decode($n))) { - throw new UnexpectedValueException('Invalid JWK encoding'); - } - if (false === ($publicExponent = JWT::urlsafeB64Decode($e))) { - throw new UnexpectedValueException('Invalid header encoding'); - } + $mod = JWT::urlsafeB64Decode($n); + $exp = JWT::urlsafeB64Decode($e); - $components = [ - 'modulus' => \pack('Ca*a*', 2, self::encodeLength(\strlen($modulus)), $modulus), - 'publicExponent' => \pack('Ca*a*', 2, self::encodeLength(\strlen($publicExponent)), $publicExponent) - ]; + $modulus = \pack('Ca*a*', 2, self::encodeLength(\strlen($mod)), $mod); + $publicExponent = \pack('Ca*a*', 2, self::encodeLength(\strlen($exp)), $exp); $rsaPublicKey = \pack( 'Ca*a*a*', 48, - self::encodeLength(\strlen($components['modulus']) + \strlen($components['publicExponent'])), - $components['modulus'], - $components['publicExponent'] + self::encodeLength(\strlen($modulus) + \strlen($publicExponent)), + $modulus, + $publicExponent ); // sequence(oid(1.2.840.113549.1.1.1), null)) = rsaEncryption. @@ -176,7 +170,7 @@ private static function createPemFromModulusAndExponent( * @param int $length * @return string */ - private static function encodeLength($length) + private static function encodeLength(int $length): string { if ($length <= 0x7F) { return \chr($length); diff --git a/src/JWT.php b/src/JWT.php index cf58fd2a..f6a4772c 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -36,13 +36,15 @@ class JWT * When checking nbf, iat or expiration times, * we want to provide some extra leeway time to * account for clock skew. + * + * @var int */ - public static int $leeway = 0; + public static $leeway = 0; /** * @var array */ - public static array $supported_algs = [ + public static $supported_algs = [ 'ES384' => ['openssl', 'SHA384'], 'ES256' => ['openssl', 'SHA256'], 'HS256' => ['hash_hmac', 'SHA256'], @@ -77,8 +79,10 @@ class JWT * @uses jsonDecode * @uses urlsafeB64Decode */ - public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray): stdClass - { + public static function decode( + string $jwt, + $keyOrKeyArray + ): stdClass { // Validate JWT $timestamp = \time(); @@ -90,24 +94,18 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray) throw new UnexpectedValueException('Wrong number of segments'); } list($headb64, $bodyb64, $cryptob64) = $tks; - if (false === ($headerRaw = static::urlsafeB64Decode($headb64))) { - throw new UnexpectedValueException('Invalid header encoding'); - } + $headerRaw = static::urlsafeB64Decode($headb64); if (null === ($header = static::jsonDecode($headerRaw))) { throw new UnexpectedValueException('Invalid header encoding'); } - if (false === ($payloadRaw = static::urlsafeB64Decode($bodyb64))) { - throw new UnexpectedValueException('Invalid claims encoding'); - } + $payloadRaw = static::urlsafeB64Decode($bodyb64); if (null === ($payload = static::jsonDecode($payloadRaw))) { throw new UnexpectedValueException('Invalid claims encoding'); } if (!$payload instanceof stdClass) { throw new UnexpectedValueException('Payload must be a JSON object'); } - if (false === ($sig = static::urlsafeB64Decode($cryptob64))) { - throw new UnexpectedValueException('Invalid signature encoding'); - } + $sig = static::urlsafeB64Decode($cryptob64); if (empty($header->alg)) { throw new UnexpectedValueException('Empty algorithm'); } @@ -159,7 +157,7 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray) * Converts and signs a PHP object or array into a JWT string. * * @param array $payload PHP array - * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key The secret key. + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. * @param string $keyId * @param array $head An array with header elements to attach * @@ -170,7 +168,7 @@ public static function decode(string $jwt, Key|array|ArrayAccess $keyOrKeyArray) */ public static function encode( array $payload, - string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key, + $key, string $alg, string $keyId = null, array $head = null @@ -197,7 +195,7 @@ public static function encode( * Sign a string with a given key and algorithm. * * @param string $msg The message to sign - * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key The secret key. + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. * @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', * 'HS512', 'RS256', 'RS384', and 'RS512' * @@ -207,7 +205,7 @@ public static function encode( */ public static function sign( string $msg, - string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $key, + $key, string $alg ): string { if (empty(static::$supported_algs[$alg])) { @@ -222,7 +220,7 @@ public static function sign( return \hash_hmac($algorithm, $msg, $key, true); case 'openssl': $signature = ''; - $success = \openssl_sign($msg, $signature, $key, $algorithm); + $success = \openssl_sign($msg, $signature, $key, $algorithm); // @phpstan-ignore-line if (!$success) { throw new DomainException("OpenSSL unable to sign data"); } @@ -258,7 +256,7 @@ public static function sign( * * @param string $msg The original message (header and body) * @param string $signature The original signature - * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey * @param string $alg The algorithm * * @return bool @@ -268,7 +266,7 @@ public static function sign( private static function verify( string $msg, string $signature, - string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial, + $keyMaterial, string $alg ): bool { if (empty(static::$supported_algs[$alg])) { @@ -278,7 +276,7 @@ private static function verify( list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'openssl': - $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); + $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); // @phpstan-ignore-line if ($success === 1) { return true; } elseif ($success === 0) { @@ -322,7 +320,7 @@ private static function verify( * * @throws DomainException Provided string was invalid JSON */ - public static function jsonDecode(string $input): mixed + public static function jsonDecode(string $input) { $obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING); @@ -339,11 +337,11 @@ public static function jsonDecode(string $input): mixed * * @param array $input A PHP array * - * @return string|false JSON representation of the PHP array + * @return string JSON representation of the PHP array * * @throws DomainException Provided object could not be encoded to valid JSON */ - public static function jsonEncode(array $input): string|false + public static function jsonEncode(array $input): string { if (PHP_VERSION_ID >= 50400) { $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); @@ -356,6 +354,9 @@ public static function jsonEncode(array $input): string|false } elseif ($json === 'null' && $input !== null) { throw new DomainException('Null result with non-null input'); } + if ($json === false) { + throw new DomainException('Provided object could not be encoded to valid JSON'); + } return $json; } @@ -365,8 +366,10 @@ public static function jsonEncode(array $input): string|false * @param string $input A Base64 encoded string * * @return string A decoded string + * + * @throws InvalidArgumentException invalid base64 characters */ - public static function urlsafeB64Decode(string $input): string|false + public static function urlsafeB64Decode(string $input): string { $remainder = \strlen($input) % 4; if ($remainder) { @@ -399,8 +402,10 @@ public static function urlsafeB64Encode(string $input): string * * @return Key */ - private static function getKey(Key|array|ArrayAccess $keyOrKeyArray, ?string $kid): Key - { + private static function getKey( + $keyOrKeyArray, + ?string $kid + ): Key { if ($keyOrKeyArray instanceof Key) { return $keyOrKeyArray; } diff --git a/src/Key.php b/src/Key.php index 2f648dec..b09ad190 100644 --- a/src/Key.php +++ b/src/Key.php @@ -9,14 +9,28 @@ class Key { + /** @var string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate */ + private $keyMaterial; + /** @var string */ + private $algorithm; + /** - * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial * @param string $algorithm */ public function __construct( - private string|OpenSSLAsymmetricKey|OpenSSLCertificate|array $keyMaterial, - private string $algorithm + $keyMaterial, + string $algorithm ) { + if ( + !is_string($keyMaterial) + && !$keyMaterial instanceof OpenSSLAsymmetricKey + && !$keyMaterial instanceof OpenSSLCertificate + && !is_resource($keyMaterial) + ) { + throw new TypeError('Key material must be a string, resource, or OpenSSLAsymmetricKey'); + } + if (empty($keyMaterial)) { throw new InvalidArgumentException('Key material must not be empty'); } @@ -24,6 +38,10 @@ public function __construct( if (empty($algorithm)) { throw new InvalidArgumentException('Algorithm must not be empty'); } + + // TODO: Remove in PHP 8.0 in favor of class constructor property promotion + $this->keyMaterial = $keyMaterial; + $this->algorithm = $algorithm; } /** @@ -37,9 +55,9 @@ public function getAlgorithm(): string } /** - * @return string|OpenSSLAsymmetricKey|OpenSSLCertificate|array + * @return string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate */ - public function getKeyMaterial(): string|OpenSSLAsymmetricKey|OpenSSLCertificate|array + public function getKeyMaterial() { return $this->keyMaterial; } From 5bbc90c14db454eea88e1ffff9ac727c8136392a Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 23 Mar 2022 11:12:10 -0700 Subject: [PATCH 13/95] chore: add back timestamp var for compatibility (#412) --- src/JWT.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index f6a4772c..843d0ae0 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -41,6 +41,15 @@ class JWT */ public static $leeway = 0; + /** + * Allow the current timestamp to be specified. + * Useful for fixing a value within unit testing. + * Will default to PHP time() value if null. + * + * @var ?int + */ + public static $timestamp = null; + /** * @var array */ @@ -84,7 +93,7 @@ public static function decode( $keyOrKeyArray ): stdClass { // Validate JWT - $timestamp = \time(); + $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; if (empty($keyOrKeyArray)) { throw new InvalidArgumentException('Key may not be empty'); From fbb2967a3a68b07e37678c00c0cf51165051495f Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 23 Mar 2022 11:26:04 -0700 Subject: [PATCH 14/95] chore: update CHANGELOG for v6.1.0 --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index d489c69f..2dd7669b 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,11 @@ $decoded = json_decode(json_encode($decoded), true); Changelog --------- +#### 6.1.0 / 2022-03-23 + + - Drop support for PHP 5.3, 5.4, 5.5, 5.6, and 7.0 + - Add parameter typing and return types where possible + #### 6.0.0 / 2022-01-24 - **Backwards-Compatibility Breaking Changes**: See the [Release Notes](https://github.com/firebase/php-jwt/releases/tag/v6.0.0) for more information. From a57a898bf7e6d3b64d8b7890d6a89bd2118c1d64 Mon Sep 17 00:00:00 2001 From: Andres Berdugo Date: Wed, 13 Apr 2022 16:32:21 -0500 Subject: [PATCH 15/95] chore: add missing docblock param for $alg (#419) --- src/JWT.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/JWT.php b/src/JWT.php index 843d0ae0..bf064e34 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -167,6 +167,8 @@ public static function decode( * * @param array $payload PHP array * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. + * @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', + * 'HS512', 'RS256', 'RS384', and 'RS512' * @param string $keyId * @param array $head An array with header elements to attach * From e67638d067a537731e3f9c03e097c7c1b1f31fe8 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 13 Apr 2022 18:27:14 -0500 Subject: [PATCH 16/95] fix: add flag to force object (#416) --- src/JWT.php | 2 +- tests/JWTTest.php | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index bf064e34..67514b29 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -355,7 +355,7 @@ public static function jsonDecode(string $input) public static function jsonEncode(array $input): string { if (PHP_VERSION_ID >= 50400) { - $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); + $json = \json_encode($input, \JSON_UNESCAPED_SLASHES|\JSON_FORCE_OBJECT); } else { // PHP 5.3 only $json = \json_encode($input); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 8b23ad6b..e1984b34 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -321,6 +321,15 @@ public function testRSEncodeDecodeWithPassphrase() $this->assertEquals($decoded, $expected); } + public function testDecodesEmptyArrayAsObject() + { + $key = 'yma6Hq4XQegCVND8ef23OYgxSrC3IKqk'; + $payload = []; + $jwt = JWT::encode($payload, $key, 'HS256'); + $decoded = JWT::decode($jwt, new Key($key, 'HS256')); + $this->assertEquals((object) $payload, $decoded); + } + /** * @runInSeparateProcess * @dataProvider provideEncodeDecode From c297139da7c6873dbd67cbd1093f09ec0bbd0c50 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 21 Apr 2022 08:37:18 -0600 Subject: [PATCH 17/95] fix: revert add flag to force object (#420) --- src/JWT.php | 6 +++++- tests/JWTTest.php | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index 67514b29..57d92ba8 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -111,6 +111,10 @@ public static function decode( if (null === ($payload = static::jsonDecode($payloadRaw))) { throw new UnexpectedValueException('Invalid claims encoding'); } + if (is_array($payload)) { + // prevent PHP Fatal Error in edge-cases when payload is empty array + $payload = (object) $payload; + } if (!$payload instanceof stdClass) { throw new UnexpectedValueException('Payload must be a JSON object'); } @@ -355,7 +359,7 @@ public static function jsonDecode(string $input) public static function jsonEncode(array $input): string { if (PHP_VERSION_ID >= 50400) { - $json = \json_encode($input, \JSON_UNESCAPED_SLASHES|\JSON_FORCE_OBJECT); + $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); } else { // PHP 5.3 only $json = \json_encode($input); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index e1984b34..191e3d2c 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -330,6 +330,15 @@ public function testDecodesEmptyArrayAsObject() $this->assertEquals((object) $payload, $decoded); } + public function testDecodesArraysInJWTAsArray() + { + $key = 'yma6Hq4XQegCVND8ef23OYgxSrC3IKqk'; + $payload = ['foo' => [1,2,3]]; + $jwt = JWT::encode($payload, $key, 'HS256'); + $decoded = JWT::decode($jwt, new Key($key, 'HS256')); + $this->assertEquals($payload['foo'], $decoded->foo); + } + /** * @runInSeparateProcess * @dataProvider provideEncodeDecode From 0ac50413aa4c045dbbcf94ff157a912352de4a8e Mon Sep 17 00:00:00 2001 From: Martin Krisell Date: Sat, 23 Apr 2022 19:36:00 +0200 Subject: [PATCH 18/95] chore: styles for short array and single quotes (#423) --- README.md | 50 +++++++++++++++++++++++------------------------ src/JWT.php | 2 +- tests/JWKTest.php | 4 ++-- tests/JWTTest.php | 44 ++++++++++++++++++++--------------------- 4 files changed, 50 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 2dd7669b..65e8fc18 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,13 @@ Example use Firebase\JWT\JWT; use Firebase\JWT\Key; -$key = "example_key"; -$payload = array( - "iss" => "http://example.org", - "aud" => "http://example.com", - "iat" => 1356999524, - "nbf" => 1357000000 -); +$key = 'example_key'; +$payload = [ + 'iss' => 'http://example.org', + 'aud' => 'http://example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; /** * IMPORTANT: @@ -98,12 +98,12 @@ ehde/zUxo6UvS7UrBQIDAQAB -----END PUBLIC KEY----- EOD; -$payload = array( - "iss" => "example.org", - "aud" => "example.com", - "iat" => 1356999524, - "nbf" => 1357000000 -); +$payload = [ + 'iss' => 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; $jwt = JWT::encode($payload, $privateKey, 'RS256'); echo "Encode:\n" . print_r($jwt, true) . "\n"; @@ -139,12 +139,12 @@ $privateKey = openssl_pkey_get_private( $passphrase ); -$payload = array( - "iss" => "example.org", - "aud" => "example.com", - "iat" => 1356999524, - "nbf" => 1357000000 -); +$payload = [ + 'iss' => 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; $jwt = JWT::encode($payload, $privateKey, 'RS256'); echo "Encode:\n" . print_r($jwt, true) . "\n"; @@ -173,12 +173,12 @@ $privateKey = base64_encode(sodium_crypto_sign_secretkey($keyPair)); $publicKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); -$payload = array( - "iss" => "example.org", - "aud" => "example.com", - "iat" => 1356999524, - "nbf" => 1357000000 -); +$payload = [ + 'iss' => 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; $jwt = JWT::encode($payload, $privateKey, 'EdDSA'); echo "Encode:\n" . print_r($jwt, true) . "\n"; diff --git a/src/JWT.php b/src/JWT.php index 57d92ba8..7db16c4b 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -237,7 +237,7 @@ public static function sign( $signature = ''; $success = \openssl_sign($msg, $signature, $key, $algorithm); // @phpstan-ignore-line if (!$success) { - throw new DomainException("OpenSSL unable to sign data"); + throw new DomainException('OpenSSL unable to sign data'); } if ($alg === 'ES256') { $signature = self::signatureFromDER($signature, 256); diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 17dd4a62..29d2356a 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -125,7 +125,7 @@ public function testDecodeByJwkKeySet() $result = JWT::decode($msg, self::$keys); - $this->assertEquals("foo", $result->sub); + $this->assertEquals('foo', $result->sub); } /** @@ -139,6 +139,6 @@ public function testDecodeByMultiJwkKeySet() $result = JWT::decode($msg, self::$keys); - $this->assertEquals("bar", $result->sub); + $this->assertEquals('bar', $result->sub); } } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 191e3d2c..98562ca1 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -36,8 +36,8 @@ public function testExpiredToken() { $this->expectException(ExpiredException::class); $payload = [ - "message" => "abc", - "exp" => time() - 20]; // time in the past + 'message' => 'abc', + 'exp' => time() - 20]; // time in the past $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); } @@ -46,7 +46,7 @@ public function testBeforeValidTokenWithNbf() { $this->expectException(BeforeValidException::class); $payload = [ - "message" => "abc", + 'message' => 'abc', "nbf" => time() + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -56,7 +56,7 @@ public function testBeforeValidTokenWithIat() { $this->expectException(BeforeValidException::class); $payload = [ - "message" => "abc", + 'message' => 'abc', "iat" => time() + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -65,8 +65,8 @@ public function testBeforeValidTokenWithIat() public function testValidToken() { $payload = [ - "message" => "abc", - "exp" => time() + JWT::$leeway + 20]; // time in the future + 'message' => 'abc', + 'exp' => time() + JWT::$leeway + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -76,8 +76,8 @@ public function testValidTokenWithLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", - "exp" => time() - 20]; // time in the past + 'message' => 'abc', + 'exp' => time() - 20]; // time in the past $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -88,8 +88,8 @@ public function testExpiredTokenWithLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", - "exp" => time() - 70]; // time far in the past + 'message' => 'abc', + 'exp' => time() - 70]; // time far in the past $this->expectException(ExpiredException::class); $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -100,9 +100,9 @@ public function testExpiredTokenWithLeeway() public function testValidTokenWithNbf() { $payload = [ - "message" => "abc", + 'message' => 'abc', "iat" => time(), - "exp" => time() + 20, // time in the future + 'exp' => time() + 20, // time in the future "nbf" => time() - 20]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -113,7 +113,7 @@ public function testValidTokenWithNbfLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", + 'message' => 'abc', "nbf" => time() + 20]; // not before in near (leeway) future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -125,7 +125,7 @@ public function testInvalidTokenWithNbfLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", + 'message' => 'abc', "nbf" => time() + 65]; // not before too far in future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(BeforeValidException::class); @@ -137,7 +137,7 @@ public function testValidTokenWithIatLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", + 'message' => 'abc', "iat" => time() + 20]; // issued in near (leeway) future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -149,7 +149,7 @@ public function testInvalidTokenWithIatLeeway() { JWT::$leeway = 60; $payload = [ - "message" => "abc", + 'message' => 'abc', "iat" => time() + 65]; // issued too far in future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(BeforeValidException::class); @@ -160,8 +160,8 @@ public function testInvalidTokenWithIatLeeway() public function testInvalidToken() { $payload = [ - "message" => "abc", - "exp" => time() + 20]; // time in the future + 'message' => 'abc', + 'exp' => time() + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(SignatureInvalidException::class); JWT::decode($encoded, new Key('my_key2', 'HS256')); @@ -170,8 +170,8 @@ public function testInvalidToken() public function testNullKeyFails() { $payload = [ - "message" => "abc", - "exp" => time() + JWT::$leeway + 20]; // time in the future + 'message' => 'abc', + 'exp' => time() + JWT::$leeway + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(TypeError::class); JWT::decode($encoded, new Key(null, 'HS256')); @@ -180,8 +180,8 @@ public function testNullKeyFails() public function testEmptyKeyFails() { $payload = [ - "message" => "abc", - "exp" => time() + JWT::$leeway + 20]; // time in the future + 'message' => 'abc', + 'exp' => time() + JWT::$leeway + 20]; // time in the future $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(InvalidArgumentException::class); JWT::decode($encoded, new Key('', 'HS256')); From 6a6025beec4ad9fbb0b47b5d5ac144810fced240 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 26 Apr 2022 10:52:40 -0600 Subject: [PATCH 19/95] chore: add cs config, fix git-attributes, removed unused entrypoint.sh (#424) --- .gitattributes | 7 +++-- .github/actions/entrypoint.sh | 21 -------------- .github/workflows/tests.yml | 5 ++-- .gitignore | 1 + .php-cs-fixer.dist.php | 24 ++++++++++++++++ src/JWT.php | 19 ++++++------- src/Key.php | 6 ++-- tests/JWKTest.php | 6 ++-- tests/JWTTest.php | 52 ++++++++++++++++++++++------------- 9 files changed, 79 insertions(+), 62 deletions(-) delete mode 100755 .github/actions/entrypoint.sh create mode 100644 .php-cs-fixer.dist.php diff --git a/.gitattributes b/.gitattributes index 6d63e560..d5d535d9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,9 @@ * text=auto -/tests export-ignore /.gitattributes export-ignore /.gitignore export-ignore -/.travis.yml export-ignore -/phpunit.xml.dist export-ignore /.github export-ignore +/.php-cs-fixer.dist.php export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore diff --git a/.github/actions/entrypoint.sh b/.github/actions/entrypoint.sh deleted file mode 100755 index 40402bc8..00000000 --- a/.github/actions/entrypoint.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/sh -l - -apt-get update && \ -apt-get install -y --no-install-recommends \ - git \ - zip \ - curl \ - ca-certificates \ - unzip \ - wget - -curl --silent --show-error https://getcomposer.org/installer | php -php composer.phar self-update - -echo "---Installing dependencies ---" - -# Add compatiblity for libsodium with older versions of PHP -php composer.phar require --dev --with-dependencies paragonie/sodium_compat - -echo "---Running unit tests ---" -vendor/bin/phpunit diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 68d4f10b..fd10d13b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,9 +38,8 @@ jobs: php-version: "8.0" - name: Run Script run: | - composer require friendsofphp/php-cs-fixer - vendor/bin/php-cs-fixer fix --diff --dry-run . - vendor/bin/php-cs-fixer fix --rules=native_function_invocation --allow-risky=yes --diff src + composer global require friendsofphp/php-cs-fixer + ~/.composer/vendor/bin/php-cs-fixer fix --diff --dry-run --allow-risky=yes . staticanalysis: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index b22842cb..f720fb76 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ phpunit.phar.asc composer.phar composer.lock .phpunit.result.cache +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 00000000..fb636632 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,24 @@ +setRules([ + '@PSR2' => true, + 'concat_space' => ['spacing' => 'one'], + 'no_unused_imports' => true, + 'ordered_imports' => true, + 'new_with_braces' => true, + 'method_argument_space' => false, + 'whitespace_after_comma_in_array' => true, + 'return_type_declaration' => [ + 'space_before' => 'none' + ], + 'single_quote' => true, + 'native_function_invocation' => [ + 'strict' => false + ], + ]) + ->setFinder( + PhpCsFixer\Finder::create() + ->in(__DIR__) + ) +; diff --git a/src/JWT.php b/src/JWT.php index 7db16c4b..98a503e4 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -2,16 +2,15 @@ namespace Firebase\JWT; -use ArrayAccess; +use DateTime; use DomainException; use Exception; use InvalidArgumentException; use OpenSSLAsymmetricKey; use OpenSSLCertificate; +use stdClass; use TypeError; use UnexpectedValueException; -use DateTime; -use stdClass; /** * JSON Web Token implementation, based on this spec: @@ -111,7 +110,7 @@ public static function decode( if (null === ($payload = static::jsonDecode($payloadRaw))) { throw new UnexpectedValueException('Invalid claims encoding'); } - if (is_array($payload)) { + if (\is_array($payload)) { // prevent PHP Fatal Error in edge-cases when payload is empty array $payload = (object) $payload; } @@ -229,7 +228,7 @@ public static function sign( list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'hash_hmac': - if (!is_string($key)) { + if (!\is_string($key)) { throw new InvalidArgumentException('key must be a string when using hmac'); } return \hash_hmac($algorithm, $msg, $key, true); @@ -246,10 +245,10 @@ public static function sign( } return $signature; case 'sodium_crypto': - if (!function_exists('sodium_crypto_sign_detached')) { + if (!\function_exists('sodium_crypto_sign_detached')) { throw new DomainException('libsodium is not available'); } - if (!is_string($key)) { + if (!\is_string($key)) { throw new InvalidArgumentException('key must be a string when using EdDSA'); } try { @@ -302,10 +301,10 @@ private static function verify( 'OpenSSL error: ' . \openssl_error_string() ); case 'sodium_crypto': - if (!function_exists('sodium_crypto_sign_verify_detached')) { + if (!\function_exists('sodium_crypto_sign_verify_detached')) { throw new DomainException('libsodium is not available'); } - if (!is_string($keyMaterial)) { + if (!\is_string($keyMaterial)) { throw new InvalidArgumentException('key must be a string when using EdDSA'); } try { @@ -318,7 +317,7 @@ private static function verify( } case 'hash_hmac': default: - if (!is_string($keyMaterial)) { + if (!\is_string($keyMaterial)) { throw new InvalidArgumentException('key must be a string when using hmac'); } $hash = \hash_hmac($algorithm, $msg, $keyMaterial, true); diff --git a/src/Key.php b/src/Key.php index b09ad190..00cf7f2e 100644 --- a/src/Key.php +++ b/src/Key.php @@ -2,10 +2,10 @@ namespace Firebase\JWT; +use InvalidArgumentException; use OpenSSLAsymmetricKey; use OpenSSLCertificate; use TypeError; -use InvalidArgumentException; class Key { @@ -23,10 +23,10 @@ public function __construct( string $algorithm ) { if ( - !is_string($keyMaterial) + !\is_string($keyMaterial) && !$keyMaterial instanceof OpenSSLAsymmetricKey && !$keyMaterial instanceof OpenSSLCertificate - && !is_resource($keyMaterial) + && !\is_resource($keyMaterial) ) { throw new TypeError('Key material must be a string, resource, or OpenSSLAsymmetricKey'); } diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 29d2356a..4167a2ba 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -2,8 +2,8 @@ namespace Firebase\JWT; -use PHPUnit\Framework\TestCase; use InvalidArgumentException; +use PHPUnit\Framework\TestCase; use UnexpectedValueException; class JWKTest extends TestCase @@ -69,7 +69,7 @@ public function testParseKeyWithEmptyDValue() $jwkSet['keys'][0]['d'] = null; $keys = JWK::parseKeySet($jwkSet); - $this->assertTrue(is_array($keys)); + $this->assertTrue(\is_array($keys)); } public function testParseJwkKeySet() @@ -79,7 +79,7 @@ public function testParseJwkKeySet() true ); $keys = JWK::parseKeySet($jwkSet); - $this->assertTrue(is_array($keys)); + $this->assertTrue(\is_array($keys)); $this->assertArrayHasKey('jwk1', $keys); self::$keys = $keys; } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 98562ca1..58c334ed 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -3,12 +3,12 @@ namespace Firebase\JWT; use ArrayObject; -use PHPUnit\Framework\TestCase; use DomainException; use InvalidArgumentException; +use PHPUnit\Framework\TestCase; +use stdClass; use TypeError; use UnexpectedValueException; -use stdClass; class JWTTest extends TestCase { @@ -37,7 +37,8 @@ public function testExpiredToken() $this->expectException(ExpiredException::class); $payload = [ 'message' => 'abc', - 'exp' => time() - 20]; // time in the past + 'exp' => time() - 20, // time in the past + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); } @@ -47,7 +48,8 @@ public function testBeforeValidTokenWithNbf() $this->expectException(BeforeValidException::class); $payload = [ 'message' => 'abc', - "nbf" => time() + 20]; // time in the future + 'nbf' => time() + 20, // time in the future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); } @@ -57,7 +59,8 @@ public function testBeforeValidTokenWithIat() $this->expectException(BeforeValidException::class); $payload = [ 'message' => 'abc', - "iat" => time() + 20]; // time in the future + 'iat' => time() + 20, // time in the future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); JWT::decode($encoded, new Key('my_key', 'HS256')); } @@ -66,7 +69,8 @@ public function testValidToken() { $payload = [ 'message' => 'abc', - 'exp' => time() + JWT::$leeway + 20]; // time in the future + 'exp' => time() + JWT::$leeway + 20, // time in the future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -77,7 +81,8 @@ public function testValidTokenWithLeeway() JWT::$leeway = 60; $payload = [ 'message' => 'abc', - 'exp' => time() - 20]; // time in the past + 'exp' => time() - 20, // time in the past + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -89,7 +94,8 @@ public function testExpiredTokenWithLeeway() JWT::$leeway = 60; $payload = [ 'message' => 'abc', - 'exp' => time() - 70]; // time far in the past + 'exp' => time() - 70, // time far in the past + ]; $this->expectException(ExpiredException::class); $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -101,9 +107,10 @@ public function testValidTokenWithNbf() { $payload = [ 'message' => 'abc', - "iat" => time(), + 'iat' => time(), 'exp' => time() + 20, // time in the future - "nbf" => time() - 20]; + 'nbf' => time() - 20 + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -114,7 +121,8 @@ public function testValidTokenWithNbfLeeway() JWT::$leeway = 60; $payload = [ 'message' => 'abc', - "nbf" => time() + 20]; // not before in near (leeway) future + 'nbf' => time() + 20, // not before in near (leeway) future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -126,7 +134,8 @@ public function testInvalidTokenWithNbfLeeway() JWT::$leeway = 60; $payload = [ 'message' => 'abc', - "nbf" => time() + 65]; // not before too far in future + 'nbf' => time() + 65, // not before too far in future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(BeforeValidException::class); JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -138,7 +147,8 @@ public function testValidTokenWithIatLeeway() JWT::$leeway = 60; $payload = [ 'message' => 'abc', - "iat" => time() + 20]; // issued in near (leeway) future + 'iat' => time() + 20, // issued in near (leeway) future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); $this->assertEquals($decoded->message, 'abc'); @@ -150,7 +160,8 @@ public function testInvalidTokenWithIatLeeway() JWT::$leeway = 60; $payload = [ 'message' => 'abc', - "iat" => time() + 65]; // issued too far in future + 'iat' => time() + 65, // issued too far in future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(BeforeValidException::class); JWT::decode($encoded, new Key('my_key', 'HS256')); @@ -161,7 +172,8 @@ public function testInvalidToken() { $payload = [ 'message' => 'abc', - 'exp' => time() + 20]; // time in the future + 'exp' => time() + 20, // time in the future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(SignatureInvalidException::class); JWT::decode($encoded, new Key('my_key2', 'HS256')); @@ -171,7 +183,8 @@ public function testNullKeyFails() { $payload = [ 'message' => 'abc', - 'exp' => time() + JWT::$leeway + 20]; // time in the future + 'exp' => time() + JWT::$leeway + 20, // time in the future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(TypeError::class); JWT::decode($encoded, new Key(null, 'HS256')); @@ -181,7 +194,8 @@ public function testEmptyKeyFails() { $payload = [ 'message' => 'abc', - 'exp' => time() + JWT::$leeway + 20]; // time in the future + 'exp' => time() + JWT::$leeway + 20, // time in the future + ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $this->expectException(InvalidArgumentException::class); JWT::decode($encoded, new Key('', 'HS256')); @@ -250,7 +264,7 @@ public function testInvalidSegmentCount() public function testInvalidSignatureEncoding() { - $msg = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6ImZvbyJ9.Q4Kee9E8o0Xfo4ADXvYA8t7dN_X_bU9K5w6tXuiSjlUxx"; + $msg = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6ImZvbyJ9.Q4Kee9E8o0Xfo4ADXvYA8t7dN_X_bU9K5w6tXuiSjlUxx'; $this->expectException(UnexpectedValueException::class); JWT::decode($msg, new Key('secret', 'HS256')); } @@ -333,7 +347,7 @@ public function testDecodesEmptyArrayAsObject() public function testDecodesArraysInJWTAsArray() { $key = 'yma6Hq4XQegCVND8ef23OYgxSrC3IKqk'; - $payload = ['foo' => [1,2,3]]; + $payload = ['foo' => [1, 2, 3]]; $jwt = JWT::encode($payload, $key, 'HS256'); $decoded = JWT::decode($jwt, new Key($key, 'HS256')); $this->assertEquals($payload['foo'], $decoded->foo); From 2308363dfd10eef9491a0c8adf4437dba94717c5 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 27 Apr 2022 16:26:15 -0600 Subject: [PATCH 20/95] feat: add cached keyset (#397) --- .github/workflows/tests.yml | 1 + README.md | 39 +++ composer.json | 7 +- phpunit.xml.dist | 2 +- src/CachedKeySet.php | 225 +++++++++++++++++ src/JWT.php | 19 +- tests/CachedKeySetTest.php | 481 ++++++++++++++++++++++++++++++++++++ tests/bootstrap.php | 14 -- 8 files changed, 761 insertions(+), 27 deletions(-) create mode 100644 src/CachedKeySet.php create mode 100644 tests/CachedKeySetTest.php delete mode 100644 tests/bootstrap.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fd10d13b..83dc88da 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -52,5 +52,6 @@ jobs: php-version: '8.0' - name: Run Script run: | + composer install composer global require phpstan/phpstan ~/.composer/vendor/bin/phpstan analyse diff --git a/README.md b/README.md index 65e8fc18..42e8b6db 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,45 @@ $jwks = ['keys' => []]; JWT::decode($payload, JWK::parseKeySet($jwks)); ``` +Using Cached Key Sets +--------------------- + +The `CachedKeySet` class can be used to fetch and cache JWKS (JSON Web Key Sets) from a public URI. +This has the following advantages: + +1. The results are cached for performance. +2. If an unrecognized key is requested, the cache is refreshed, to accomodate for key rotation. +3. If rate limiting is enabled, the JWKS URI will not make more than 10 requests a second. + +```php +use Firebase\JWT\CachedKeySet; +use Firebase\JWT\JWT; + +// The URI for the JWKS you wish to cache the results from +$jwksUri = 'https://www.gstatic.com/iap/verify/public_key-jwk'; + +// Create an HTTP client (can be any PSR-7 compatible HTTP client) +$httpClient = new GuzzleHttp\Client(); + +// Create an HTTP request factory (can be any PSR-17 compatible HTTP request factory) +$httpFactory = new GuzzleHttp\Psr\HttpFactory(); + +// Create a cache item pool (can be any PSR-6 compatible cache item pool) +$cacheItemPool = Phpfastcache\CacheManager::getInstance('files'); + +$keySet = new CachedKeySet( + $jwksUri, + $httpClient, + $httpFactory, + $cacheItemPool, + null, // $expiresAfter int seconds to set the JWKS to expire + true // $rateLimit true to enable rate limit of 10 RPS on lookup of invalid keys +); + +$jwt = 'eyJhbGci...'; // Some JWT signed by a key from the $jwkUri above +$decoded = JWT::decode($jwt, $keySet); +``` + Miscellaneous ------------- diff --git a/composer.json b/composer.json index 5ef2ea2d..2a3cb2df 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,11 @@ } }, "require-dev": { - "phpunit/phpunit": "^7.5||9.5" + "guzzlehttp/guzzle": "^6.5||^7.4", + "phpspec/prophecy-phpunit": "^1.1", + "phpunit/phpunit": "^7.5||^9.5", + "psr/cache": "^1.0||^2.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 092a662c..31195a91 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,7 +8,7 @@ convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" - bootstrap="tests/bootstrap.php" + bootstrap="vendor/autoload.php" > diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php new file mode 100644 index 00000000..077dceb0 --- /dev/null +++ b/src/CachedKeySet.php @@ -0,0 +1,225 @@ + + */ +class CachedKeySet implements ArrayAccess +{ + /** + * @var string + */ + private $jwksUri; + /** + * @var ClientInterface + */ + private $httpClient; + /** + * @var RequestFactoryInterface + */ + private $httpFactory; + /** + * @var CacheItemPoolInterface + */ + private $cache; + /** + * @var ?int + */ + private $expiresAfter; + /** + * @var ?CacheItemInterface + */ + private $cacheItem; + /** + * @var array + */ + private $keySet; + /** + * @var string + */ + private $cacheKey; + /** + * @var string + */ + private $cacheKeyPrefix = 'jwks'; + /** + * @var int + */ + private $maxKeyLength = 64; + /** + * @var bool + */ + private $rateLimit; + /** + * @var string + */ + private $rateLimitCacheKey; + /** + * @var int + */ + private $maxCallsPerMinute = 10; + + public function __construct( + string $jwksUri, + ClientInterface $httpClient, + RequestFactoryInterface $httpFactory, + CacheItemPoolInterface $cache, + int $expiresAfter = null, + bool $rateLimit = false + ) { + $this->jwksUri = $jwksUri; + $this->httpClient = $httpClient; + $this->httpFactory = $httpFactory; + $this->cache = $cache; + $this->expiresAfter = $expiresAfter; + $this->rateLimit = $rateLimit; + $this->setCacheKeys(); + } + + /** + * @param string $keyId + * @return Key + */ + public function offsetGet($keyId): Key + { + if (!$this->keyIdExists($keyId)) { + throw new OutOfBoundsException('Key ID not found'); + } + return $this->keySet[$keyId]; + } + + /** + * @param string $keyId + * @return bool + */ + public function offsetExists($keyId): bool + { + return $this->keyIdExists($keyId); + } + + /** + * @param string $offset + * @param Key $value + */ + public function offsetSet($offset, $value): void + { + throw new LogicException('Method not implemented'); + } + + /** + * @param string $offset + */ + public function offsetUnset($offset): void + { + throw new LogicException('Method not implemented'); + } + + private function keyIdExists(string $keyId): bool + { + $keySetToCache = null; + if (null === $this->keySet) { + $item = $this->getCacheItem(); + // Try to load keys from cache + if ($item->isHit()) { + // item found! Return it + $this->keySet = $item->get(); + } + } + + if (!isset($this->keySet[$keyId])) { + if ($this->rateLimitExceeded()) { + return false; + } + $request = $this->httpFactory->createRequest('get', $this->jwksUri); + $jwksResponse = $this->httpClient->sendRequest($request); + $jwks = json_decode((string) $jwksResponse->getBody(), true); + $this->keySet = $keySetToCache = JWK::parseKeySet($jwks); + + if (!isset($this->keySet[$keyId])) { + return false; + } + } + + if ($keySetToCache) { + $item = $this->getCacheItem(); + $item->set($keySetToCache); + if ($this->expiresAfter) { + $item->expiresAfter($this->expiresAfter); + } + $this->cache->save($item); + } + + return true; + } + + private function rateLimitExceeded(): bool + { + if (!$this->rateLimit) { + return false; + } + + $cacheItem = $this->cache->getItem($this->rateLimitCacheKey); + if (!$cacheItem->isHit()) { + $cacheItem->expiresAfter(1); // # of calls are cached each minute + } + + $callsPerMinute = (int) $cacheItem->get(); + if (++$callsPerMinute > $this->maxCallsPerMinute) { + return true; + } + $cacheItem->set($callsPerMinute); + $this->cache->save($cacheItem); + return false; + } + + private function getCacheItem(): CacheItemInterface + { + if (\is_null($this->cacheItem)) { + $this->cacheItem = $this->cache->getItem($this->cacheKey); + } + + return $this->cacheItem; + } + + private function setCacheKeys(): void + { + if (empty($this->jwksUri)) { + throw new RuntimeException('JWKS URI is empty'); + } + + // ensure we do not have illegal characters + $key = preg_replace('|[^a-zA-Z0-9_\.!]|', '', $this->jwksUri); + + // add prefix + $key = $this->cacheKeyPrefix . $key; + + // Hash keys if they exceed $maxKeyLength of 64 + if (\strlen($key) > $this->maxKeyLength) { + $key = substr(hash('sha256', $key), 0, $this->maxKeyLength); + } + + $this->cacheKey = $key; + + if ($this->rateLimit) { + // add prefix + $rateLimitKey = $this->cacheKeyPrefix . 'ratelimit' . $key; + + // Hash keys if they exceed $maxKeyLength of 64 + if (\strlen($rateLimitKey) > $this->maxKeyLength) { + $rateLimitKey = substr(hash('sha256', $rateLimitKey), 0, $this->maxKeyLength); + } + + $this->rateLimitCacheKey = $rateLimitKey; + } + } +} diff --git a/src/JWT.php b/src/JWT.php index 98a503e4..9011292f 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -2,6 +2,7 @@ namespace Firebase\JWT; +use ArrayAccess; use DateTime; use DomainException; use Exception; @@ -9,7 +10,6 @@ use OpenSSLAsymmetricKey; use OpenSSLCertificate; use stdClass; -use TypeError; use UnexpectedValueException; /** @@ -68,7 +68,7 @@ class JWT * Decodes a JWT string into a PHP object. * * @param string $jwt The JWT - * @param Key|array $keyOrKeyArray The Key or associative array of key IDs (kid) to Key objects. + * @param Key|array $keyOrKeyArray The Key or associative array of key IDs (kid) to Key objects. * If the algorithm used is asymmetric, this is the public key * Each Key object contains an algorithm and matching key. * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', @@ -409,7 +409,7 @@ public static function urlsafeB64Encode(string $input): string /** * Determine if an algorithm has been provided for each Key * - * @param Key|array $keyOrKeyArray + * @param Key|ArrayAccess|array $keyOrKeyArray * @param string|null $kid * * @throws UnexpectedValueException @@ -424,15 +424,12 @@ private static function getKey( return $keyOrKeyArray; } - foreach ($keyOrKeyArray as $keyId => $key) { - if (!$key instanceof Key) { - throw new TypeError( - '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an ' - . 'array of Firebase\JWT\Key keys' - ); - } + if ($keyOrKeyArray instanceof CachedKeySet) { + // Skip "isset" check, as this will automatically refresh if not set + return $keyOrKeyArray[$kid]; } - if (!isset($kid)) { + + if (empty($kid)) { throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); } if (!isset($keyOrKeyArray[$kid])) { diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php new file mode 100644 index 00000000..22e1de5f --- /dev/null +++ b/tests/CachedKeySetTest.php @@ -0,0 +1,481 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage('JWKS URI is empty'); + + $cachedKeySet = new CachedKeySet( + '', + $this->prophesize(ClientInterface::class)->reveal(), + $this->prophesize(RequestFactoryInterface::class)->reveal(), + $this->prophesize(CacheItemPoolInterface::class)->reveal() + ); + + $cachedKeySet['foo']; + } + + public function testOffsetSetThrowsException() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Method not implemented'); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->prophesize(ClientInterface::class)->reveal(), + $this->prophesize(RequestFactoryInterface::class)->reveal(), + $this->prophesize(CacheItemPoolInterface::class)->reveal() + ); + + $cachedKeySet['foo'] = 'bar'; + } + + public function testOffsetUnsetThrowsException() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Method not implemented'); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->prophesize(ClientInterface::class)->reveal(), + $this->prophesize(RequestFactoryInterface::class)->reveal(), + $this->prophesize(CacheItemPoolInterface::class)->reveal() + ); + + unset($cachedKeySet['foo']); + } + + public function testOutOfBoundsThrowsException() + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Key ID not found'); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks1), + $this->getMockHttpFactory(), + $this->getMockEmptyCache() + ); + + // keyID doesn't exist + $cachedKeySet['bar']; + } + + public function testWithExistingKeyId() + { + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks1), + $this->getMockHttpFactory(), + $this->getMockEmptyCache() + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); + $this->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + } + + public function testKeyIdIsCached() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->willReturn(true); + $cacheItem->get() + ->willReturn(JWK::parseKeySet(json_decode($this->testJwks1, true))); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem($this->testJwksUriKey) + ->willReturn($cacheItem->reveal()); + $cache->save(Argument::any()) + ->willReturn(true); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->prophesize(ClientInterface::class)->reveal(), + $this->prophesize(RequestFactoryInterface::class)->reveal(), + $cache->reveal() + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); + $this->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + } + + public function testCachedKeyIdRefresh() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->shouldBeCalledOnce() + ->willReturn(true); + $cacheItem->get() + ->shouldBeCalledOnce() + ->willReturn(JWK::parseKeySet(json_decode($this->testJwks1, true))); + $cacheItem->set(Argument::any()) + ->shouldBeCalledOnce() + ->will(function () { + return $this; + }); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem($this->testJwksUriKey) + ->shouldBeCalledOnce() + ->willReturn($cacheItem->reveal()); + $cache->save(Argument::any()) + ->shouldBeCalledOnce() + ->willReturn(true); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks2), // updated JWK + $this->getMockHttpFactory(), + $cache->reveal() + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); + $this->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + + $this->assertInstanceOf(Key::class, $cachedKeySet['bar']); + $this->assertEquals('bar', $cachedKeySet['bar']->getAlgorithm()); + } + + public function testCacheItemWithExpiresAfter() + { + $expiresAfter = 10; + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->shouldBeCalledOnce() + ->willReturn(false); + $cacheItem->set(Argument::any()) + ->shouldBeCalledOnce() + ->will(function () { + return $this; + }); + $cacheItem->expiresAfter($expiresAfter) + ->shouldBeCalledOnce() + ->will(function () { + return $this; + }); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem($this->testJwksUriKey) + ->shouldBeCalledOnce() + ->willReturn($cacheItem->reveal()); + $cache->save(Argument::any()) + ->shouldBeCalledOnce(); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks1), + $this->getMockHttpFactory(), + $cache->reveal(), + $expiresAfter + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); + $this->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + } + + public function testJwtVerify() + { + $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); + $payload = array('sub' => 'foo', 'exp' => strtotime('+10 seconds')); + $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); + + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->willReturn(true); + $cacheItem->get() + ->willReturn(JWK::parseKeySet( + json_decode(file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), true) + )); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem($this->testJwksUriKey) + ->willReturn($cacheItem->reveal()); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->prophesize(ClientInterface::class)->reveal(), + $this->prophesize(RequestFactoryInterface::class)->reveal(), + $cache->reveal() + ); + + $result = JWT::decode($msg, $cachedKeySet); + + $this->assertEquals('foo', $result->sub); + } + + public function testRateLimit() + { + // We request the key 11 times, HTTP should only be called 10 times + $shouldBeCalledTimes = 10; + + // Instantiate the cached key set + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks1, $shouldBeCalledTimes), + $factory = $this->getMockHttpFactory($shouldBeCalledTimes), + new TestMemoryCacheItemPool(), + 10, // expires after seconds + true // enable rate limiting + ); + + $invalidKid = 'invalidkey'; + for ($i = 0; $i < 10; $i++) { + $this->assertFalse(isset($cachedKeySet[$invalidKid])); + } + // The 11th time does not call HTTP + $this->assertFalse(isset($cachedKeySet[$invalidKid])); + } + + /** + * @dataProvider provideFullIntegration + */ + public function testFullIntegration(string $jwkUri): void + { + if (!class_exists(\GuzzleHttp\Psr7\HttpFactory::class)) { + self::markTestSkipped('Guzzle 7 only'); + } + // Create cache and http objects + $cache = new TestMemoryCacheItemPool(); + $http = new \GuzzleHttp\Client(); + $factory = new \GuzzleHttp\Psr7\HttpFactory(); + + // Determine "kid" dynamically, because these constantly change + $response = $http->get($jwkUri); + $json = (string) $response->getBody(); + $keys = json_decode($json, true); + $kid = $keys['keys'][0]['kid'] ?? null; + $this->assertNotNull($kid); + + // Instantiate the cached key set + $cachedKeySet = new CachedKeySet( + $jwkUri, + $http, + $factory, + $cache + ); + + $this->assertArrayHasKey($kid, $cachedKeySet); + $key = $cachedKeySet[$kid]; + $this->assertInstanceOf(Key::class, $key); + $this->assertEquals($keys['keys'][0]['alg'], $key->getAlgorithm()); + } + + public function provideFullIntegration() + { + return [ + [$this->googleRsaUri], + // [$this->googleEcUri, 'LYyP2g'] + ]; + } + + private function getMockHttpClient($testJwks, int $timesCalled = 1) + { + $body = $this->prophesize('Psr\Http\Message\StreamInterface'); + $body->__toString() + ->shouldBeCalledTimes($timesCalled) + ->willReturn($testJwks); + + $response = $this->prophesize('Psr\Http\Message\ResponseInterface'); + $response->getBody() + ->shouldBeCalledTimes($timesCalled) + ->willReturn($body->reveal()); + + $http = $this->prophesize(ClientInterface::class); + $http->sendRequest(Argument::any()) + ->shouldBeCalledTimes($timesCalled) + ->willReturn($response->reveal()); + + return $http->reveal(); + } + + private function getMockHttpFactory(int $timesCalled = 1) + { + $request = $this->prophesize('Psr\Http\Message\RequestInterface'); + $factory = $this->prophesize(RequestFactoryInterface::class); + $factory->createRequest('get', $this->testJwksUri) + ->shouldBeCalledTimes($timesCalled) + ->willReturn($request->reveal()); + + return $factory->reveal(); + } + + private function getMockEmptyCache() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->shouldBeCalledOnce() + ->willReturn(false); + $cacheItem->set(Argument::any()) + ->will(function () { + return $this; + }); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem($this->testJwksUriKey) + ->shouldBeCalledOnce() + ->willReturn($cacheItem->reveal()); + $cache->save(Argument::any()) + ->willReturn(true); + + return $cache->reveal(); + } +} + +/** + * A cache item pool + */ +final class TestMemoryCacheItemPool implements CacheItemPoolInterface +{ + private $items; + private $deferredItems; + + public function getItem($key): CacheItemInterface + { + return current($this->getItems([$key])); + } + + public function getItems(array $keys = []): iterable + { + $items = []; + + foreach ($keys as $key) { + $items[$key] = $this->hasItem($key) ? clone $this->items[$key] : new TestMemoryCacheItem($key); + } + + return $items; + } + + public function hasItem($key): bool + { + return isset($this->items[$key]) && $this->items[$key]->isHit(); + } + + public function clear(): bool + { + $this->items = []; + $this->deferredItems = []; + + return true; + } + + public function deleteItem($key): bool + { + return $this->deleteItems([$key]); + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + unset($this->items[$key]); + } + + return true; + } + + public function save(CacheItemInterface $item): bool + { + $this->items[$item->getKey()] = $item; + + return true; + } + + public function saveDeferred(CacheItemInterface $item): bool + { + $this->deferredItems[$item->getKey()] = $item; + + return true; + } + + public function commit(): bool + { + foreach ($this->deferredItems as $item) { + $this->save($item); + } + + $this->deferredItems = []; + + return true; + } +} + +/** + * A cache item. + */ +final class TestMemoryCacheItem implements CacheItemInterface +{ + private $key; + private $value; + private $expiration; + private $isHit = false; + + public function __construct(string $key) + { + $this->key = $key; + } + + public function getKey(): string + { + return $this->key; + } + + public function get() + { + return $this->isHit() ? $this->value : null; + } + + public function isHit(): bool + { + if (!$this->isHit) { + return false; + } + + if ($this->expiration === null) { + return true; + } + + return $this->currentTime()->getTimestamp() < $this->expiration->getTimestamp(); + } + + public function set($value) + { + $this->isHit = true; + $this->value = $value; + + return $this; + } + + public function expiresAt($expiration) + { + $this->expiration = $expiration; + return $this; + } + + public function expiresAfter($time) + { + $this->expiration = $this->currentTime()->add(new \DateInterval("PT{$time}S")); + return $this; + } + + protected function currentTime() + { + return new \DateTime('now', new \DateTimeZone('UTC')); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index 385b6706..00000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,14 +0,0 @@ - Date: Fri, 13 May 2022 13:54:50 -0700 Subject: [PATCH 21/95] feat: add defaultAlg param (#426) --- src/CachedKeySet.php | 10 ++++++++-- src/JWK.php | 22 +++++++++++++++------- tests/CachedKeySetTest.php | 16 ++++++++++++++++ tests/JWKTest.php | 12 ++++++++++++ 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index 077dceb0..f1580c92 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -68,6 +68,10 @@ class CachedKeySet implements ArrayAccess * @var int */ private $maxCallsPerMinute = 10; + /** + * @var string|null + */ + private $defaultAlg; public function __construct( string $jwksUri, @@ -75,7 +79,8 @@ public function __construct( RequestFactoryInterface $httpFactory, CacheItemPoolInterface $cache, int $expiresAfter = null, - bool $rateLimit = false + bool $rateLimit = false, + string $defaultAlg = null ) { $this->jwksUri = $jwksUri; $this->httpClient = $httpClient; @@ -83,6 +88,7 @@ public function __construct( $this->cache = $cache; $this->expiresAfter = $expiresAfter; $this->rateLimit = $rateLimit; + $this->defaultAlg = $defaultAlg; $this->setCacheKeys(); } @@ -143,7 +149,7 @@ private function keyIdExists(string $keyId): bool $request = $this->httpFactory->createRequest('get', $this->jwksUri); $jwksResponse = $this->httpClient->sendRequest($request); $jwks = json_decode((string) $jwksResponse->getBody(), true); - $this->keySet = $keySetToCache = JWK::parseKeySet($jwks); + $this->keySet = $keySetToCache = JWK::parseKeySet($jwks, $this->defaultAlg); if (!isset($this->keySet[$keyId])) { return false; diff --git a/src/JWK.php b/src/JWK.php index dbc446e6..7f225701 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -24,6 +24,8 @@ class JWK * Parse a set of JWK keys * * @param array $jwks The JSON Web Key Set as an associative array + * @param string $defaultAlg The algorithm for the Key object if "alg" is not set in the + * JSON Web Key Set * * @return array An associative array of key IDs (kid) to Key objects * @@ -33,7 +35,7 @@ class JWK * * @uses parseKey */ - public static function parseKeySet(array $jwks): array + public static function parseKeySet(array $jwks, string $defaultAlg = null): array { $keys = []; @@ -47,7 +49,7 @@ public static function parseKeySet(array $jwks): array foreach ($jwks['keys'] as $k => $v) { $kid = isset($v['kid']) ? $v['kid'] : $k; - if ($key = self::parseKey($v)) { + if ($key = self::parseKey($v, $defaultAlg)) { $keys[(string) $kid] = $key; } } @@ -63,6 +65,8 @@ public static function parseKeySet(array $jwks): array * Parse a JWK key * * @param array $jwk An individual JWK + * @param string $defaultAlg The algorithm for the Key object if "alg" is not set in the + * JSON Web Key Set * * @return Key The key object for the JWK * @@ -72,7 +76,7 @@ public static function parseKeySet(array $jwks): array * * @uses createPemFromModulusAndExponent */ - public static function parseKey(array $jwk): ?Key + public static function parseKey(array $jwk, string $defaultAlg = null): ?Key { if (empty($jwk)) { throw new InvalidArgumentException('JWK must not be empty'); @@ -83,10 +87,14 @@ public static function parseKey(array $jwk): ?Key } if (!isset($jwk['alg'])) { - // The "alg" parameter is optional in a KTY, but is required for parsing in - // this library. Add it manually to your JWK array if it doesn't already exist. - // @see https://datatracker.ietf.org/doc/html/rfc7517#section-4.4 - throw new UnexpectedValueException('JWK must contain an "alg" parameter'); + if (\is_null($defaultAlg)) { + // The "alg" parameter is optional in a KTY, but an algorithm is required + // for parsing in this library. Use the $defaultAlg parameter when parsing the + // key set in order to prevent this error. + // @see https://datatracker.ietf.org/doc/html/rfc7517#section-4.4 + throw new UnexpectedValueException('JWK must contain an "alg" parameter'); + } + $jwk['alg'] = $defaultAlg; } switch ($jwk['kty']) { diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 22e1de5f..a13c80fd 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -18,6 +18,7 @@ class CachedKeySetTest extends TestCase private $testJwksUriKey = 'jwkshttpsjwk.uri'; private $testJwks1 = '{"keys": [{"kid":"foo","kty":"RSA","alg":"foo","n":"","e":""}]}'; private $testJwks2 = '{"keys": [{"kid":"bar","kty":"RSA","alg":"bar","n":"","e":""}]}'; + private $testJwks3 = '{"keys": [{"kid":"baz","kty":"RSA","n":"","e":""}]}'; private $googleRsaUri = 'https://www.googleapis.com/oauth2/v3/certs'; // private $googleEcUri = 'https://www.gstatic.com/iap/verify/public_key-jwk'; @@ -95,6 +96,21 @@ public function testWithExistingKeyId() $this->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); } + public function testWithDefaultAlg() + { + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks3), + $this->getMockHttpFactory(), + $this->getMockEmptyCache(), + null, + false, + 'baz256' + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['baz']); + $this->assertEquals('baz256', $cachedKeySet['baz']->getAlgorithm()); + } + public function testKeyIdIsCached() { $cacheItem = $this->prophesize(CacheItemInterface::class); diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 4167a2ba..0bd4f636 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -58,6 +58,18 @@ public function testParsePrivateKeyWithoutAlg() JWK::parseKeySet($jwkSet); } + public function testParsePrivateKeyWithoutAlgWithDefaultAlgParameter() + { + $jwkSet = json_decode( + file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), + true + ); + unset($jwkSet['keys'][0]['alg']); + + $jwks = JWK::parseKeySet($jwkSet, 'foo'); + $this->assertEquals('foo', $jwks['jwk1']->getAlgorithm()); + } + public function testParseKeyWithEmptyDValue() { $jwkSet = json_decode( From 64ed2e50c733f914014a2c1e7b5e6d2e3bb4e979 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 14 Jul 2022 16:17:28 -0600 Subject: [PATCH 22/95] feat: Add ES256 support to JWK (#399) --- src/JWK.php | 133 ++++++++++++++++++++++++++++++++ tests/JWKTest.php | 24 ++++-- tests/data/ec-jwkset.json | 22 ++++++ tests/data/ecdsa256-private.pem | 4 + 4 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 tests/data/ec-jwkset.json create mode 100644 tests/data/ecdsa256-private.pem diff --git a/src/JWK.php b/src/JWK.php index 7f225701..c90de4e1 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -20,6 +20,16 @@ */ class JWK { + private const OID = '1.2.840.10045.2.1'; + private const ASN1_OBJECT_IDENTIFIER = 0x06; + private const ASN1_SEQUENCE = 0x10; // also defined in JWT + private const ASN1_BIT_STRING = 0x03; + private const EC_CURVES = [ + 'P-256' => '1.2.840.10045.3.1.7', // Len: 64 + // 'P-384' => '1.3.132.0.34', // Len: 96 (not yet supported) + // 'P-521' => '1.3.132.0.35', // Len: 132 (not supported) + ]; + /** * Parse a set of JWK keys * @@ -114,6 +124,26 @@ public static function parseKey(array $jwk, string $defaultAlg = null): ?Key ); } return new Key($publicKey, $jwk['alg']); + case 'EC': + if (isset($jwk['d'])) { + // The key is actually a private key + throw new UnexpectedValueException('Key data must be for a public key'); + } + + if (empty($jwk['crv'])) { + throw new UnexpectedValueException('crv not set'); + } + + if (!isset(self::EC_CURVES[$jwk['crv']])) { + throw new DomainException('Unrecognised or unsupported EC curve'); + } + + if (empty($jwk['x']) || empty($jwk['y'])) { + throw new UnexpectedValueException('x and y not set'); + } + + $publicKey = self::createPemFromCrvAndXYCoordinates($jwk['crv'], $jwk['x'], $jwk['y']); + return new Key($publicKey, $jwk['alg']); default: // Currently only RSA is supported break; @@ -122,6 +152,45 @@ public static function parseKey(array $jwk, string $defaultAlg = null): ?Key return null; } + /** + * Converts the EC JWK values to pem format. + * + * @param string $crv The EC curve (only P-256 is supported) + * @param string $x The EC x-coordinate + * @param string $y The EC y-coordinate + * + * @return string + */ + private static function createPemFromCrvAndXYCoordinates(string $crv, string $x, string $y): string + { + $pem = + self::encodeDER( + self::ASN1_SEQUENCE, + self::encodeDER( + self::ASN1_SEQUENCE, + self::encodeDER( + self::ASN1_OBJECT_IDENTIFIER, + self::encodeOID(self::OID) + ) + . self::encodeDER( + self::ASN1_OBJECT_IDENTIFIER, + self::encodeOID(self::EC_CURVES[$crv]) + ) + ) . + self::encodeDER( + self::ASN1_BIT_STRING, + chr(0x00) . chr(0x04) + . JWT::urlsafeB64Decode($x) + . JWT::urlsafeB64Decode($y) + ) + ); + + return sprintf( + "-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n", + wordwrap(base64_encode($pem), 64, "\n", true) + ); + } + /** * Create a public key represented in PEM format from RSA modulus and exponent information * @@ -188,4 +257,68 @@ private static function encodeLength(int $length): string return \pack('Ca*', 0x80 | \strlen($temp), $temp); } + + /** + * Encodes a value into a DER object. + * Also defined in Firebase\JWT\JWT + * + * @param int $type DER tag + * @param string $value the value to encode + * @return string the encoded object + */ + private static function encodeDER(int $type, string $value): string + { + $tag_header = 0; + if ($type === self::ASN1_SEQUENCE) { + $tag_header |= 0x20; + } + + // Type + $der = \chr($tag_header | $type); + + // Length + $der .= \chr(\strlen($value)); + + return $der . $value; + } + + /** + * Encodes a string into a DER-encoded OID. + * + * @param string $oid the OID string + * @return string the binary DER-encoded OID + */ + private static function encodeOID(string $oid): string + { + $octets = explode('.', $oid); + + // Get the first octet + $first = (int) array_shift($octets); + $second = (int) array_shift($octets); + $oid = chr($first * 40 + $second); + + // Iterate over subsequent octets + foreach ($octets as $octet) { + if ($octet == 0) { + $oid .= chr(0x00); + continue; + } + $bin = ''; + + while ($octet) { + $bin .= chr(0x80 | ($octet & 0x7f)); + $octet >>= 7; + } + $bin[0] = $bin[0] & chr(0x7f); + + // Convert to big endian if necessary + if (pack('V', 65534) == pack('L', 65534)) { + $oid .= strrev($bin); + } else { + $oid .= $bin; + } + } + + return $oid; + } } diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 0bd4f636..b8c24f98 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -127,19 +127,33 @@ public function testDecodeByJwkKeySetTokenExpired() } /** - * @depends testParseJwkKeySet + * @dataProvider provideDecodeByJwkKeySet */ - public function testDecodeByJwkKeySet() + public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg) { - $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); + $privKey1 = file_get_contents(__DIR__ . '/data/' . $pemFile); $payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')]; - $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); + $msg = JWT::encode($payload, $privKey1, $alg, 'jwk1'); - $result = JWT::decode($msg, self::$keys); + $jwkSet = json_decode( + file_get_contents(__DIR__ . '/data/' . $jwkFile), + true + ); + + $keys = JWK::parseKeySet($jwkSet); + $result = JWT::decode($msg, $keys); $this->assertEquals('foo', $result->sub); } + public function provideDecodeByJwkKeySet() + { + return [ + ['rsa1-private.pem', 'rsa-jwkset.json', 'RS256'], + ['ecdsa256-private.pem', 'ec-jwkset.json', 'ES256'], + ]; + } + /** * @depends testParseJwkKeySet */ diff --git a/tests/data/ec-jwkset.json b/tests/data/ec-jwkset.json new file mode 100644 index 00000000..46ed8cf9 --- /dev/null +++ b/tests/data/ec-jwkset.json @@ -0,0 +1,22 @@ +{ + "keys": [ + { + "kty": "EC", + "use": "sig", + "crv": "P-256", + "kid": "jwk1", + "x": "ALXnvdCvbBx35J2bozBkIFHPT747KiYioLK4JquMhZU", + "y": "fAt_rGPqS95Ytwdluh4TNWTmj9xkcAbKGBRpP5kuGBk", + "alg": "ES256" + }, + { + "kty": "EC", + "use": "sig", + "crv": "P-256", + "kid": "jwk2", + "x": "mQa0q5FvxPRujxzFazQT1Mo2YJJzuKiXU3svOJ41jhw", + "y": "jAz7UwIl2oOFk06kj42ZFMOXmGMFUGjKASvyYtibCH0", + "alg": "ES256" + } + ] +} \ No newline at end of file diff --git a/tests/data/ecdsa256-private.pem b/tests/data/ecdsa256-private.pem new file mode 100644 index 00000000..02b8f1b8 --- /dev/null +++ b/tests/data/ecdsa256-private.pem @@ -0,0 +1,4 @@ +-----BEGIN PRIVATE KEY----- +MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCD0KvVxLJEzRBQmcEXf +D2okKCNoUwZY8fc1/1Z4aJuJdg== +-----END PRIVATE KEY----- \ No newline at end of file From c68f2a7ad00b58f39b9713fbbae36246c7856200 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 14 Jul 2022 16:49:39 -0600 Subject: [PATCH 23/95] chore: fix styles and phpstan (#440) --- src/JWK.php | 10 +++++----- src/JWT.php | 30 +++++++++++++++--------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/JWK.php b/src/JWK.php index c90de4e1..a5dbc765 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -179,7 +179,7 @@ private static function createPemFromCrvAndXYCoordinates(string $crv, string $x, ) . self::encodeDER( self::ASN1_BIT_STRING, - chr(0x00) . chr(0x04) + \chr(0x00) . \chr(0x04) . JWT::urlsafeB64Decode($x) . JWT::urlsafeB64Decode($y) ) @@ -295,21 +295,21 @@ private static function encodeOID(string $oid): string // Get the first octet $first = (int) array_shift($octets); $second = (int) array_shift($octets); - $oid = chr($first * 40 + $second); + $oid = \chr($first * 40 + $second); // Iterate over subsequent octets foreach ($octets as $octet) { if ($octet == 0) { - $oid .= chr(0x00); + $oid .= \chr(0x00); continue; } $bin = ''; while ($octet) { - $bin .= chr(0x80 | ($octet & 0x7f)); + $bin .= \chr(0x80 | ($octet & 0x7f)); $octet >>= 7; } - $bin[0] = $bin[0] & chr(0x7f); + $bin[0] = $bin[0] & \chr(0x7f); // Convert to big endian if necessary if (pack('V', 65534) == pack('L', 65534)) { diff --git a/src/JWT.php b/src/JWT.php index 9011292f..084c4a45 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -301,20 +301,20 @@ private static function verify( 'OpenSSL error: ' . \openssl_error_string() ); case 'sodium_crypto': - if (!\function_exists('sodium_crypto_sign_verify_detached')) { - throw new DomainException('libsodium is not available'); - } - if (!\is_string($keyMaterial)) { - throw new InvalidArgumentException('key must be a string when using EdDSA'); - } - try { - // The last non-empty line is used as the key. - $lines = array_filter(explode("\n", $keyMaterial)); - $key = base64_decode((string) end($lines)); - return sodium_crypto_sign_verify_detached($signature, $msg, $key); - } catch (Exception $e) { - throw new DomainException($e->getMessage(), 0, $e); - } + if (!\function_exists('sodium_crypto_sign_verify_detached')) { + throw new DomainException('libsodium is not available'); + } + if (!\is_string($keyMaterial)) { + throw new InvalidArgumentException('key must be a string when using EdDSA'); + } + try { + // The last non-empty line is used as the key. + $lines = array_filter(explode("\n", $keyMaterial)); + $key = base64_decode((string) end($lines)); + return sodium_crypto_sign_verify_detached($signature, $msg, $key); + } catch (Exception $e) { + throw new DomainException($e->getMessage(), 0, $e); + } case 'hash_hmac': default: if (!\is_string($keyMaterial)) { @@ -510,7 +510,7 @@ private static function signatureToDER(string $sig): string { // Separate the signature into r-value and s-value $length = max(1, (int) (\strlen($sig) / 2)); - list($r, $s) = \str_split($sig, $length > 0 ? $length : 1); + list($r, $s) = \str_split($sig, $length); // Trim leading zeros $r = \ltrim($r, "\x00"); From 607dcd47b0e68d7367823f50df4f38fb24d71a04 Mon Sep 17 00:00:00 2001 From: Brando Meniconi Date: Fri, 15 Jul 2022 18:09:18 +0200 Subject: [PATCH 24/95] fix: cache jwks as string in CachedKeySet (#435) --- src/CachedKeySet.php | 12 +++++------- tests/CachedKeySetTest.php | 9 ++++----- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index f1580c92..e2215b30 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -132,13 +132,13 @@ public function offsetUnset($offset): void private function keyIdExists(string $keyId): bool { - $keySetToCache = null; if (null === $this->keySet) { $item = $this->getCacheItem(); // Try to load keys from cache if ($item->isHit()) { // item found! Return it - $this->keySet = $item->get(); + $jwks = $item->get(); + $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg); } } @@ -148,17 +148,15 @@ private function keyIdExists(string $keyId): bool } $request = $this->httpFactory->createRequest('get', $this->jwksUri); $jwksResponse = $this->httpClient->sendRequest($request); - $jwks = json_decode((string) $jwksResponse->getBody(), true); - $this->keySet = $keySetToCache = JWK::parseKeySet($jwks, $this->defaultAlg); + $jwks = (string) $jwksResponse->getBody(); + $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg); if (!isset($this->keySet[$keyId])) { return false; } - } - if ($keySetToCache) { $item = $this->getCacheItem(); - $item->set($keySetToCache); + $item->set($jwks); if ($this->expiresAfter) { $item->expiresAfter($this->expiresAfter); } diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index a13c80fd..9e884e6b 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -117,7 +117,7 @@ public function testKeyIdIsCached() $cacheItem->isHit() ->willReturn(true); $cacheItem->get() - ->willReturn(JWK::parseKeySet(json_decode($this->testJwks1, true))); + ->willReturn($this->testJwks1); $cache = $this->prophesize(CacheItemPoolInterface::class); $cache->getItem($this->testJwksUriKey) @@ -143,7 +143,7 @@ public function testCachedKeyIdRefresh() ->willReturn(true); $cacheItem->get() ->shouldBeCalledOnce() - ->willReturn(JWK::parseKeySet(json_decode($this->testJwks1, true))); + ->willReturn($this->testJwks1); $cacheItem->set(Argument::any()) ->shouldBeCalledOnce() ->will(function () { @@ -217,9 +217,8 @@ public function testJwtVerify() $cacheItem->isHit() ->willReturn(true); $cacheItem->get() - ->willReturn(JWK::parseKeySet( - json_decode(file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), true) - )); + ->willReturn(file_get_contents(__DIR__ . '/data/rsa-jwkset.json') + ); $cache = $this->prophesize(CacheItemPoolInterface::class); $cache->getItem($this->testJwksUriKey) From 1494082c0cbd9bb342b64681af0275265dad47ed Mon Sep 17 00:00:00 2001 From: mehdihasanpour <47119405+mehdihasanpour@users.noreply.github.com> Date: Fri, 15 Jul 2022 20:58:30 +0430 Subject: [PATCH 25/95] chore: misc cleanup (#439) --- src/JWK.php | 4 +--- src/JWT.php | 9 +++++---- tests/CachedKeySetTest.php | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/JWK.php b/src/JWK.php index a5dbc765..15631ecc 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -231,11 +231,9 @@ private static function createPemFromModulusAndExponent( $rsaOID . $rsaPublicKey ); - $rsaPublicKey = "-----BEGIN PUBLIC KEY-----\r\n" . + return "-----BEGIN PUBLIC KEY-----\r\n" . \chunk_split(\base64_encode($rsaPublicKey), 64) . '-----END PUBLIC KEY-----'; - - return $rsaPublicKey; } /** diff --git a/src/JWT.php b/src/JWT.php index 084c4a45..9964073d 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -98,7 +98,7 @@ public static function decode( throw new InvalidArgumentException('Key may not be empty'); } $tks = \explode('.', $jwt); - if (\count($tks) != 3) { + if (\count($tks) !== 3) { throw new UnexpectedValueException('Wrong number of segments'); } list($headb64, $bodyb64, $cryptob64) = $tks; @@ -136,7 +136,7 @@ public static function decode( // OpenSSL expects an ASN.1 DER sequence for ES256/ES384 signatures $sig = self::signatureToDER($sig); } - if (!self::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) { + if (!self::verify("${headb64}.${bodyb64}", $sig, $key->getKeyMaterial(), $header->alg)) { throw new SignatureInvalidException('Signature verification failed'); } @@ -293,7 +293,8 @@ private static function verify( $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); // @phpstan-ignore-line if ($success === 1) { return true; - } elseif ($success === 0) { + } + if ($success === 0) { return false; } // returns 1 on success, 0 on failure, -1 on error. @@ -610,7 +611,7 @@ private static function readDER(string $der, int $offset = 0): array } // Value - if ($type == self::ASN1_BIT_STRING) { + if ($type === self::ASN1_BIT_STRING) { $pos++; // Skip the first contents octet (padding indicator) $data = \substr($der, $pos, $len - 1); $pos += $len - 1; diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 9e884e6b..1dc2fdc0 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -210,7 +210,7 @@ public function testCacheItemWithExpiresAfter() public function testJwtVerify() { $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem'); - $payload = array('sub' => 'foo', 'exp' => strtotime('+10 seconds')); + $payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')]; $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); $cacheItem = $this->prophesize(CacheItemInterface::class); From 608668efd4c87335da32fb2e8a2e680163342273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ShaoBo=20Wan=28=E7=84=A1=E5=B0=98=29?= <756684177@qq.com> Date: Sat, 16 Jul 2022 00:45:03 +0800 Subject: [PATCH 26/95] chore: update changelog for v6.2.0 (#434) --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 42e8b6db..aa90baee 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,11 @@ $decoded = json_decode(json_encode($decoded), true); Changelog --------- +#### 6.2.0 / 2022-05-14 + + - Added `CachedKeySet` ([#397](https://github.com/firebase/php-jwt/pull/397)) + - Added `$defaultAlg` parameter to `JWT::parseKey` and `JWT::parseKeySet` ([#426](https://github.com/firebase/php-jwt/pull/426)). + #### 6.1.0 / 2022-03-23 - Drop support for PHP 5.3, 5.4, 5.5, 5.6, and 7.0 From 018dfc4e1da92ad8a1b90adc4893f476a3b41cb8 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 15 Jul 2022 10:48:45 -0600 Subject: [PATCH 27/95] chore: prepare v6.3.0 (#441) --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index aa90baee..fed1e954 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,11 @@ $decoded = json_decode(json_encode($decoded), true); Changelog --------- +#### 6.3.0 / 2022-07-15 + + - Added ES256 support to JWK parsing ([#399](https://github.com/firebase/php-jwt/pull/399)) + - Fixed potential caching error in `CachedKeySet` by caching jwks as strings ([#435](https://github.com/firebase/php-jwt/pull/435)) + #### 6.2.0 / 2022-05-14 - Added `CachedKeySet` ([#397](https://github.com/firebase/php-jwt/pull/397)) From 60b52b71978790eafcf3b95cfbd83db0439e8d22 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 5 Oct 2022 11:57:44 -0600 Subject: [PATCH 28/95] fix: casing of GET for PSR compat (#451) --- src/CachedKeySet.php | 2 +- tests/CachedKeySetTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index e2215b30..87f470d7 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -146,7 +146,7 @@ private function keyIdExists(string $keyId): bool if ($this->rateLimitExceeded()) { return false; } - $request = $this->httpFactory->createRequest('get', $this->jwksUri); + $request = $this->httpFactory->createRequest('GET', $this->jwksUri); $jwksResponse = $this->httpClient->sendRequest($request); $jwks = (string) $jwksResponse->getBody(); $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg); diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 1dc2fdc0..73b1213d 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -325,7 +325,7 @@ private function getMockHttpFactory(int $timesCalled = 1) { $request = $this->prophesize('Psr\Http\Message\RequestInterface'); $factory = $this->prophesize(RequestFactoryInterface::class); - $factory->createRequest('get', $this->testJwksUri) + $factory->createRequest('GET', $this->testJwksUri) ->shouldBeCalledTimes($timesCalled) ->willReturn($request->reveal()); From 2e07d8a1524d12b69b110ad649f17461d068b8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Vin=C3=ADcius=20Santos=20da=20Costa=20Barros?= Date: Wed, 5 Oct 2022 16:38:41 -0300 Subject: [PATCH 29/95] fix: string interpolation format for php 8.2 (#446) --- src/JWT.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index 9964073d..5a5640ab 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -136,7 +136,7 @@ public static function decode( // OpenSSL expects an ASN.1 DER sequence for ES256/ES384 signatures $sig = self::signatureToDER($sig); } - if (!self::verify("${headb64}.${bodyb64}", $sig, $key->getKeyMaterial(), $header->alg)) { + if (!self::verify("{$headb64}.{$bodyb64}", $sig, $key->getKeyMaterial(), $header->alg)) { throw new SignatureInvalidException('Signature verification failed'); } From 1cd213749b131a088a636586a344b0018fe8a3bb Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 5 Oct 2022 13:50:38 -0600 Subject: [PATCH 30/95] chore(docs): add examples for exception handling (#463) --- README.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- src/JWT.php | 2 +- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fed1e954..79a29ec0 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,56 @@ $decoded = JWT::decode($jwt, $keySet); Miscellaneous ------------- +#### Exception Handling + +When a call to `JWT::decode` is invalid, it will throw one of the following exceptions: + +```php +use Firebase\JWT\JWT; +use Firebase\JWT\SignatureInvalidException; +use Firebase\JWT\BeforeValidException; +use Firebase\JWT\ExpiredException; +use DomainException; +use InvalidArgumentException; +use UnexpectedValueException; + +try { + $decoded = JWT::decode($payload, $keys); +} catch (InvalidArgumentException $e) { + // provided key/key-array is empty or malformed. +} catch (DomainException $e) { + // provided algorithm is unsupported OR + // provided key is invalid OR + // unknown error thrown in openSSL or libsodium OR + // libsodium is required but not available. +} catch (SignatureInvalidException $e) { + // provided JWT signature verification failed. +} catch (BeforeValidException $e) { + // provided JWT is trying to be used before "nbf" claim OR + // provided JWT is trying to be used before "iat" claim. +} catch (ExpiredException $e) { + // provided JWT is trying to be used after "exp" claim. +} catch (UnexpectedValueException $e) { + // provided JWT is malformed OR + // provided JWT is missing an algorithm / using an unsupported algorithm OR + // provided JWT algorithm does not match provided key OR + // provided key ID in key/key-array is empty or invalid. +} +``` + +All exceptions in the `Firebase\JWT` namespace extend `UnexpectedValueException`, and can be simplified +like this: + +```php +try { + $decoded = JWT::decode($payload, $keys); +} catch (LogicException $e) { + // errors having to do with environmental setup or malformed JWT Keys +} catch (UnexpectedValueException $e) { + // errors having to do with JWT signature and claims +} +``` + #### Casting to array The return value of `JWT::decode` is the generic PHP object `stdClass`. If you'd like to handle with arrays @@ -269,7 +319,7 @@ Changelog #### 6.2.0 / 2022-05-14 - Added `CachedKeySet` ([#397](https://github.com/firebase/php-jwt/pull/397)) - - Added `$defaultAlg` parameter to `JWT::parseKey` and `JWT::parseKeySet` ([#426](https://github.com/firebase/php-jwt/pull/426)). + - Added `$defaultAlg` parameter to `JWT::parseKey` and `JWT::parseKeySet` ([#426](https://github.com/firebase/php-jwt/pull/426)). #### 6.1.0 / 2022-03-23 diff --git a/src/JWT.php b/src/JWT.php index 5a5640ab..f5b466d5 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -76,7 +76,7 @@ class JWT * * @return stdClass The JWT's payload as a PHP object * - * @throws InvalidArgumentException Provided key/key-array was empty + * @throws InvalidArgumentException Provided key/key-array was empty or malformed * @throws DomainException Provided JWT is malformed * @throws UnexpectedValueException Provided JWT was invalid * @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed From f7886d510050fda6d976afe3aba77e8d082f42d0 Mon Sep 17 00:00:00 2001 From: Thibault RICHARD Date: Wed, 5 Oct 2022 21:51:08 +0200 Subject: [PATCH 31/95] chore(docs): encode does not accept objects (#457) --- src/JWT.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index f5b466d5..977d7fbd 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -166,7 +166,7 @@ public static function decode( } /** - * Converts and signs a PHP object or array into a JWT string. + * Converts and signs a PHP array into a JWT string. * * @param array $payload PHP array * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. From b4998d720d011929380a5565336baa4a76a16d85 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 1 Nov 2022 14:55:48 -0600 Subject: [PATCH 32/95] chore: add release-please (#467) --- .github/release-please.yml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .github/release-please.yml diff --git a/.github/release-please.yml b/.github/release-please.yml new file mode 100644 index 00000000..0a6e0cc2 --- /dev/null +++ b/.github/release-please.yml @@ -0,0 +1,3 @@ +releaseType: simple +handleGHRelease: true +primaryBranch: main From 436fa9cf6f9f2df47bea24016165c8e003dfee70 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 1 Nov 2022 15:06:07 -0600 Subject: [PATCH 33/95] chore: move changelog into separate file (#469) --- CHANGELOG.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 85 ---------------------------------------------------- 2 files changed, 84 insertions(+), 85 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..261150b9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,84 @@ +# Changelog + +## 6.3.0 / 2022-07-15 + + - Added ES256 support to JWK parsing ([#399](https://github.com/firebase/php-jwt/pull/399)) + - Fixed potential caching error in `CachedKeySet` by caching jwks as strings ([#435](https://github.com/firebase/php-jwt/pull/435)) + +## 6.2.0 / 2022-05-14 + + - Added `CachedKeySet` ([#397](https://github.com/firebase/php-jwt/pull/397)) + - Added `$defaultAlg` parameter to `JWT::parseKey` and `JWT::parseKeySet` ([#426](https://github.com/firebase/php-jwt/pull/426)). + +## 6.1.0 / 2022-03-23 + + - Drop support for PHP 5.3, 5.4, 5.5, 5.6, and 7.0 + - Add parameter typing and return types where possible + +## 6.0.0 / 2022-01-24 + + - **Backwards-Compatibility Breaking Changes**: See the [Release Notes](https://github.com/firebase/php-jwt/releases/tag/v6.0.0) for more information. + - New Key object to prevent key/algorithm type confusion (#365) + - Add JWK support (#273) + - Add ES256 support (#256) + - Add ES384 support (#324) + - Add Ed25519 support (#343) + +## 5.0.0 / 2017-06-26 +- Support RS384 and RS512. + See [#117](https://github.com/firebase/php-jwt/pull/117). Thanks [@joostfaassen](https://github.com/joostfaassen)! +- Add an example for RS256 openssl. + See [#125](https://github.com/firebase/php-jwt/pull/125). Thanks [@akeeman](https://github.com/akeeman)! +- Detect invalid Base64 encoding in signature. + See [#162](https://github.com/firebase/php-jwt/pull/162). Thanks [@psignoret](https://github.com/psignoret)! +- Update `JWT::verify` to handle OpenSSL errors. + See [#159](https://github.com/firebase/php-jwt/pull/159). Thanks [@bshaffer](https://github.com/bshaffer)! +- Add `array` type hinting to `decode` method + See [#101](https://github.com/firebase/php-jwt/pull/101). Thanks [@hywak](https://github.com/hywak)! +- Add all JSON error types. + See [#110](https://github.com/firebase/php-jwt/pull/110). Thanks [@gbalduzzi](https://github.com/gbalduzzi)! +- Bugfix 'kid' not in given key list. + See [#129](https://github.com/firebase/php-jwt/pull/129). Thanks [@stampycode](https://github.com/stampycode)! +- Miscellaneous cleanup, documentation and test fixes. + See [#107](https://github.com/firebase/php-jwt/pull/107), [#115](https://github.com/firebase/php-jwt/pull/115), + [#160](https://github.com/firebase/php-jwt/pull/160), [#161](https://github.com/firebase/php-jwt/pull/161), and + [#165](https://github.com/firebase/php-jwt/pull/165). Thanks [@akeeman](https://github.com/akeeman), + [@chinedufn](https://github.com/chinedufn), and [@bshaffer](https://github.com/bshaffer)! + +## 4.0.0 / 2016-07-17 +- Add support for late static binding. See [#88](https://github.com/firebase/php-jwt/pull/88) for details. Thanks to [@chappy84](https://github.com/chappy84)! +- Use static `$timestamp` instead of `time()` to improve unit testing. See [#93](https://github.com/firebase/php-jwt/pull/93) for details. Thanks to [@josephmcdermott](https://github.com/josephmcdermott)! +- Fixes to exceptions classes. See [#81](https://github.com/firebase/php-jwt/pull/81) for details. Thanks to [@Maks3w](https://github.com/Maks3w)! +- Fixes to PHPDoc. See [#76](https://github.com/firebase/php-jwt/pull/76) for details. Thanks to [@akeeman](https://github.com/akeeman)! + +## 3.0.0 / 2015-07-22 +- Minimum PHP version updated from `5.2.0` to `5.3.0`. +- Add `\Firebase\JWT` namespace. See +[#59](https://github.com/firebase/php-jwt/pull/59) for details. Thanks to +[@Dashron](https://github.com/Dashron)! +- Require a non-empty key to decode and verify a JWT. See +[#60](https://github.com/firebase/php-jwt/pull/60) for details. Thanks to +[@sjones608](https://github.com/sjones608)! +- Cleaner documentation blocks in the code. See +[#62](https://github.com/firebase/php-jwt/pull/62) for details. Thanks to +[@johanderuijter](https://github.com/johanderuijter)! + +## 2.2.0 / 2015-06-22 +- Add support for adding custom, optional JWT headers to `JWT::encode()`. See +[#53](https://github.com/firebase/php-jwt/pull/53/files) for details. Thanks to +[@mcocaro](https://github.com/mcocaro)! + +## 2.1.0 / 2015-05-20 +- Add support for adding a leeway to `JWT:decode()` that accounts for clock skew +between signing and verifying entities. Thanks to [@lcabral](https://github.com/lcabral)! +- Add support for passing an object implementing the `ArrayAccess` interface for +`$keys` argument in `JWT::decode()`. Thanks to [@aztech-dev](https://github.com/aztech-dev)! + +## 2.0.0 / 2015-04-01 +- **Note**: It is strongly recommended that you update to > v2.0.0 to address + known security vulnerabilities in prior versions when both symmetric and + asymmetric keys are used together. +- Update signature for `JWT::decode(...)` to require an array of supported + algorithms to use when verifying token signatures. + + diff --git a/README.md b/README.md index 79a29ec0..ae2b3895 100644 --- a/README.md +++ b/README.md @@ -308,91 +308,6 @@ $decoded = JWT::decode($payload, $keys); $decoded = json_decode(json_encode($decoded), true); ``` -Changelog ---------- - -#### 6.3.0 / 2022-07-15 - - - Added ES256 support to JWK parsing ([#399](https://github.com/firebase/php-jwt/pull/399)) - - Fixed potential caching error in `CachedKeySet` by caching jwks as strings ([#435](https://github.com/firebase/php-jwt/pull/435)) - -#### 6.2.0 / 2022-05-14 - - - Added `CachedKeySet` ([#397](https://github.com/firebase/php-jwt/pull/397)) - - Added `$defaultAlg` parameter to `JWT::parseKey` and `JWT::parseKeySet` ([#426](https://github.com/firebase/php-jwt/pull/426)). - -#### 6.1.0 / 2022-03-23 - - - Drop support for PHP 5.3, 5.4, 5.5, 5.6, and 7.0 - - Add parameter typing and return types where possible - -#### 6.0.0 / 2022-01-24 - - - **Backwards-Compatibility Breaking Changes**: See the [Release Notes](https://github.com/firebase/php-jwt/releases/tag/v6.0.0) for more information. - - New Key object to prevent key/algorithm type confusion (#365) - - Add JWK support (#273) - - Add ES256 support (#256) - - Add ES384 support (#324) - - Add Ed25519 support (#343) - -#### 5.0.0 / 2017-06-26 -- Support RS384 and RS512. - See [#117](https://github.com/firebase/php-jwt/pull/117). Thanks [@joostfaassen](https://github.com/joostfaassen)! -- Add an example for RS256 openssl. - See [#125](https://github.com/firebase/php-jwt/pull/125). Thanks [@akeeman](https://github.com/akeeman)! -- Detect invalid Base64 encoding in signature. - See [#162](https://github.com/firebase/php-jwt/pull/162). Thanks [@psignoret](https://github.com/psignoret)! -- Update `JWT::verify` to handle OpenSSL errors. - See [#159](https://github.com/firebase/php-jwt/pull/159). Thanks [@bshaffer](https://github.com/bshaffer)! -- Add `array` type hinting to `decode` method - See [#101](https://github.com/firebase/php-jwt/pull/101). Thanks [@hywak](https://github.com/hywak)! -- Add all JSON error types. - See [#110](https://github.com/firebase/php-jwt/pull/110). Thanks [@gbalduzzi](https://github.com/gbalduzzi)! -- Bugfix 'kid' not in given key list. - See [#129](https://github.com/firebase/php-jwt/pull/129). Thanks [@stampycode](https://github.com/stampycode)! -- Miscellaneous cleanup, documentation and test fixes. - See [#107](https://github.com/firebase/php-jwt/pull/107), [#115](https://github.com/firebase/php-jwt/pull/115), - [#160](https://github.com/firebase/php-jwt/pull/160), [#161](https://github.com/firebase/php-jwt/pull/161), and - [#165](https://github.com/firebase/php-jwt/pull/165). Thanks [@akeeman](https://github.com/akeeman), - [@chinedufn](https://github.com/chinedufn), and [@bshaffer](https://github.com/bshaffer)! - -#### 4.0.0 / 2016-07-17 -- Add support for late static binding. See [#88](https://github.com/firebase/php-jwt/pull/88) for details. Thanks to [@chappy84](https://github.com/chappy84)! -- Use static `$timestamp` instead of `time()` to improve unit testing. See [#93](https://github.com/firebase/php-jwt/pull/93) for details. Thanks to [@josephmcdermott](https://github.com/josephmcdermott)! -- Fixes to exceptions classes. See [#81](https://github.com/firebase/php-jwt/pull/81) for details. Thanks to [@Maks3w](https://github.com/Maks3w)! -- Fixes to PHPDoc. See [#76](https://github.com/firebase/php-jwt/pull/76) for details. Thanks to [@akeeman](https://github.com/akeeman)! - -#### 3.0.0 / 2015-07-22 -- Minimum PHP version updated from `5.2.0` to `5.3.0`. -- Add `\Firebase\JWT` namespace. See -[#59](https://github.com/firebase/php-jwt/pull/59) for details. Thanks to -[@Dashron](https://github.com/Dashron)! -- Require a non-empty key to decode and verify a JWT. See -[#60](https://github.com/firebase/php-jwt/pull/60) for details. Thanks to -[@sjones608](https://github.com/sjones608)! -- Cleaner documentation blocks in the code. See -[#62](https://github.com/firebase/php-jwt/pull/62) for details. Thanks to -[@johanderuijter](https://github.com/johanderuijter)! - -#### 2.2.0 / 2015-06-22 -- Add support for adding custom, optional JWT headers to `JWT::encode()`. See -[#53](https://github.com/firebase/php-jwt/pull/53/files) for details. Thanks to -[@mcocaro](https://github.com/mcocaro)! - -#### 2.1.0 / 2015-05-20 -- Add support for adding a leeway to `JWT:decode()` that accounts for clock skew -between signing and verifying entities. Thanks to [@lcabral](https://github.com/lcabral)! -- Add support for passing an object implementing the `ArrayAccess` interface for -`$keys` argument in `JWT::decode()`. Thanks to [@aztech-dev](https://github.com/aztech-dev)! - -#### 2.0.0 / 2015-04-01 -- **Note**: It is strongly recommended that you update to > v2.0.0 to address - known security vulnerabilities in prior versions when both symmetric and - asymmetric keys are used together. -- Update signature for `JWT::decode(...)` to require an array of supported - algorithms to use when verifying token signatures. - - Tests ----- Run the tests using phpunit: From ddfaddcb520488b42bca3a75e17e9dd53c3667da Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 1 Nov 2022 14:20:08 -0700 Subject: [PATCH 34/95] chore(main): release 6.3.1 (#468) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 261150b9..21d26679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [6.3.1](https://github.com/firebase/php-jwt/compare/v6.3.0...v6.3.1) (2022-11-01) + + +### Bug Fixes + +* casing of GET for PSR compat ([#451](https://github.com/firebase/php-jwt/issues/451)) ([60b52b7](https://github.com/firebase/php-jwt/commit/60b52b71978790eafcf3b95cfbd83db0439e8d22)) +* string interpolation format for php 8.2 ([#446](https://github.com/firebase/php-jwt/issues/446)) ([2e07d8a](https://github.com/firebase/php-jwt/commit/2e07d8a1524d12b69b110ad649f17461d068b8f2)) + ## 6.3.0 / 2022-07-15 - Added ES256 support to JWK parsing ([#399](https://github.com/firebase/php-jwt/pull/399)) From bad1b040d0c736bbf86814c6b5ae614f517cf7bd Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 1 Nov 2022 14:24:10 -0700 Subject: [PATCH 35/95] fix: check kid before using as array index --- src/JWT.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 977d7fbd..30b4cbb7 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -425,14 +425,15 @@ private static function getKey( return $keyOrKeyArray; } + if (empty($kid)) { + throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); + } + if ($keyOrKeyArray instanceof CachedKeySet) { // Skip "isset" check, as this will automatically refresh if not set return $keyOrKeyArray[$kid]; } - if (empty($kid)) { - throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); - } if (!isset($keyOrKeyArray[$kid])) { throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); } From ea7dda77098b96e666c5ef382452f94841e439cd Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 19 Dec 2022 09:10:46 -0800 Subject: [PATCH 36/95] chore(main): release 6.3.2 (#470) --- CHANGELOG.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d26679..d43c9d8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [6.3.2](https://github.com/firebase/php-jwt/compare/v6.3.1...v6.3.2) (2022-11-01) + + +### Bug Fixes + +* check kid before using as array index ([bad1b04](https://github.com/firebase/php-jwt/commit/bad1b040d0c736bbf86814c6b5ae614f517cf7bd)) + ## [6.3.1](https://github.com/firebase/php-jwt/compare/v6.3.0...v6.3.1) (2022-11-01) @@ -88,5 +95,3 @@ between signing and verifying entities. Thanks to [@lcabral](https://github.com/ asymmetric keys are used together. - Update signature for `JWT::decode(...)` to require an array of supported algorithms to use when verifying token signatures. - - From 7df47ddc8ca219f7fa08f469cb0aab3e1e6bf580 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 19 Dec 2022 14:07:05 -0800 Subject: [PATCH 37/95] chore: add PHP 8.2 to test suite (#476) --- .github/workflows/tests.yml | 2 +- src/JWT.php | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 83dc88da..6956aa95 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ "7.1", "7.2", "7.3", "7.4", "8.0", "8.1"] + php: [ "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2" ] name: PHP ${{matrix.php }} Unit Test steps: - uses: actions/checkout@v2 diff --git a/src/JWT.php b/src/JWT.php index 30b4cbb7..623e7055 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -255,6 +255,9 @@ public static function sign( // The last non-empty line is used as the key. $lines = array_filter(explode("\n", $key)); $key = base64_decode((string) end($lines)); + if (\strlen($key) === 0) { + throw new DomainException('Key cannot be empty string'); + } return sodium_crypto_sign_detached($msg, $key); } catch (Exception $e) { throw new DomainException($e->getMessage(), 0, $e); @@ -312,6 +315,12 @@ private static function verify( // The last non-empty line is used as the key. $lines = array_filter(explode("\n", $keyMaterial)); $key = base64_decode((string) end($lines)); + if (\strlen($key) === 0) { + throw new DomainException('Key cannot be empty string'); + } + if (\strlen($signature) === 0) { + throw new DomainException('Signature cannot be empty string'); + } return sodium_crypto_sign_verify_detached($signature, $msg, $key); } catch (Exception $e) { throw new DomainException($e->getMessage(), 0, $e); From 01786794e22800d95238335359609a356ee6fa01 Mon Sep 17 00:00:00 2001 From: "Chun-Sheng, Li" Date: Tue, 20 Dec 2022 06:08:43 +0800 Subject: [PATCH 38/95] chore: suggest ext-sodium (#474) Co-authored-by: Brent Shaffer --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2a3cb2df..c9aa3dbb 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "php": "^7.1||^8.0" }, "suggest": { - "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "ext-sodium": "Support EdDSA (Ed25519) signatures" }, "autoload": { "psr-4": { From 213924f51936291fbbca99158b11bd4ae56c2c95 Mon Sep 17 00:00:00 2001 From: Mitch Flindell <990013+scrummitch@users.noreply.github.com> Date: Tue, 20 Dec 2022 09:15:50 +1100 Subject: [PATCH 39/95] feat: add support for W3C ES256K (#462) --- src/JWK.php | 1 + src/JWT.php | 15 ++++++++------- tests/JWTTest.php | 1 + tests/data/ec-jwkset.json | 9 +++++++++ tests/data/secp256k1-private.pem | 5 +++++ tests/data/secp256k1-public.pem | 4 ++++ 6 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 tests/data/secp256k1-private.pem create mode 100644 tests/data/secp256k1-public.pem diff --git a/src/JWK.php b/src/JWK.php index 15631ecc..c7eff8ae 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -26,6 +26,7 @@ class JWK private const ASN1_BIT_STRING = 0x03; private const EC_CURVES = [ 'P-256' => '1.2.840.10045.3.1.7', // Len: 64 + 'secp256k1' => '1.3.132.0.10', // Len: 64 // 'P-384' => '1.3.132.0.34', // Len: 96 (not yet supported) // 'P-521' => '1.3.132.0.35', // Len: 132 (not supported) ]; diff --git a/src/JWT.php b/src/JWT.php index 623e7055..269e8caf 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -55,6 +55,7 @@ class JWT public static $supported_algs = [ 'ES384' => ['openssl', 'SHA384'], 'ES256' => ['openssl', 'SHA256'], + 'ES256K' => ['openssl', 'SHA256'], 'HS256' => ['hash_hmac', 'SHA256'], 'HS384' => ['hash_hmac', 'SHA384'], 'HS512' => ['hash_hmac', 'SHA512'], @@ -132,8 +133,8 @@ public static function decode( // See issue #351 throw new UnexpectedValueException('Incorrect key for this algorithm'); } - if ($header->alg === 'ES256' || $header->alg === 'ES384') { - // OpenSSL expects an ASN.1 DER sequence for ES256/ES384 signatures + if (\in_array($header->alg, ['ES256', 'ES256K', 'ES384'], true)) { + // OpenSSL expects an ASN.1 DER sequence for ES256/ES256K/ES384 signatures $sig = self::signatureToDER($sig); } if (!self::verify("{$headb64}.{$bodyb64}", $sig, $key->getKeyMaterial(), $header->alg)) { @@ -170,8 +171,8 @@ public static function decode( * * @param array $payload PHP array * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. - * @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', - * 'HS512', 'RS256', 'RS384', and 'RS512' + * @param string $alg Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256', + * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' * @param string $keyId * @param array $head An array with header elements to attach * @@ -210,8 +211,8 @@ public static function encode( * * @param string $msg The message to sign * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. - * @param string $alg Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', - * 'HS512', 'RS256', 'RS384', and 'RS512' + * @param string $alg Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256', + * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' * * @return string An encrypted message * @@ -238,7 +239,7 @@ public static function sign( if (!$success) { throw new DomainException('OpenSSL unable to sign data'); } - if ($alg === 'ES256') { + if ($alg === 'ES256' || $alg === 'ES256K') { $signature = self::signatureFromDER($signature, 256); } elseif ($alg === 'ES384') { $signature = self::signatureFromDER($signature, 384); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 58c334ed..e3b95144 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -377,6 +377,7 @@ public function provideEncodeDecode() [__DIR__ . '/data/ecdsa384-private.pem', __DIR__ . '/data/ecdsa384-public.pem', 'ES384'], [__DIR__ . '/data/rsa1-private.pem', __DIR__ . '/data/rsa1-public.pub', 'RS512'], [__DIR__ . '/data/ed25519-1.sec', __DIR__ . '/data/ed25519-1.pub', 'EdDSA'], + [__DIR__ . '/data/secp256k1-private.pem', __DIR__ . '/data/secp256k1-public.pem', 'ES256K'], ]; } diff --git a/tests/data/ec-jwkset.json b/tests/data/ec-jwkset.json index 46ed8cf9..213f68ac 100644 --- a/tests/data/ec-jwkset.json +++ b/tests/data/ec-jwkset.json @@ -17,6 +17,15 @@ "x": "mQa0q5FvxPRujxzFazQT1Mo2YJJzuKiXU3svOJ41jhw", "y": "jAz7UwIl2oOFk06kj42ZFMOXmGMFUGjKASvyYtibCH0", "alg": "ES256" + }, + { + "kty": "EC", + "use": "sig", + "crv": "secp256k1", + "kid": "jwk3", + "x": "EFpwNuP322bU3WP1DtJgx67L0CUV1MxNixqPVMH2L9Q", + "y": "_fSTbijIJjpsqL16cIEvxxf3MaYMY8MbqEq066yV9ls", + "alg": "ES256K" } ] } \ No newline at end of file diff --git a/tests/data/secp256k1-private.pem b/tests/data/secp256k1-private.pem new file mode 100644 index 00000000..3e69d585 --- /dev/null +++ b/tests/data/secp256k1-private.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgC8ouvv1ZOmOjh5Nbwx6i +3b35wWN+OEkW2hzm3BKAQJ2hRANCAAT9nYGLVP6Unm/LXOoyWhsKpalffMSr3EHV +iUE8gVmj2/atnPkblx38Yj6bC3z1urERAB+JqgpWOAKaWcEYCUuO +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/tests/data/secp256k1-public.pem b/tests/data/secp256k1-public.pem new file mode 100644 index 00000000..5949135f --- /dev/null +++ b/tests/data/secp256k1-public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAE/Z2Bi1T+lJ5vy1zqMlobCqWpX3zEq9xB +1YlBPIFZo9v2rZz5G5cd/GI+mwt89bqxEQAfiaoKVjgCmlnBGAlLjg== +-----END PUBLIC KEY----- \ No newline at end of file From 08c7ba62a8a276418cb8b5ed4be31e2b268bf829 Mon Sep 17 00:00:00 2001 From: "Chun-Sheng, Li" Date: Wed, 11 Jan 2023 07:39:17 +0800 Subject: [PATCH 40/95] chore(tests): use assertSame for strict equals (#478) --- tests/CachedKeySetTest.php | 16 ++++++++-------- tests/JWKTest.php | 6 +++--- tests/JWTTest.php | 20 ++++++++++---------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 73b1213d..91d4a27c 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -93,7 +93,7 @@ public function testWithExistingKeyId() $this->getMockEmptyCache() ); $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); - $this->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); } public function testWithDefaultAlg() @@ -108,7 +108,7 @@ public function testWithDefaultAlg() 'baz256' ); $this->assertInstanceOf(Key::class, $cachedKeySet['baz']); - $this->assertEquals('baz256', $cachedKeySet['baz']->getAlgorithm()); + $this->assertSame('baz256', $cachedKeySet['baz']->getAlgorithm()); } public function testKeyIdIsCached() @@ -132,7 +132,7 @@ public function testKeyIdIsCached() $cache->reveal() ); $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); - $this->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); } public function testCachedKeyIdRefresh() @@ -165,10 +165,10 @@ public function testCachedKeyIdRefresh() $cache->reveal() ); $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); - $this->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); $this->assertInstanceOf(Key::class, $cachedKeySet['bar']); - $this->assertEquals('bar', $cachedKeySet['bar']->getAlgorithm()); + $this->assertSame('bar', $cachedKeySet['bar']->getAlgorithm()); } public function testCacheItemWithExpiresAfter() @@ -204,7 +204,7 @@ public function testCacheItemWithExpiresAfter() $expiresAfter ); $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); - $this->assertEquals('foo', $cachedKeySet['foo']->getAlgorithm()); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); } public function testJwtVerify() @@ -233,7 +233,7 @@ public function testJwtVerify() $result = JWT::decode($msg, $cachedKeySet); - $this->assertEquals('foo', $result->sub); + $this->assertSame('foo', $result->sub); } public function testRateLimit() @@ -290,7 +290,7 @@ public function testFullIntegration(string $jwkUri): void $this->assertArrayHasKey($kid, $cachedKeySet); $key = $cachedKeySet[$kid]; $this->assertInstanceOf(Key::class, $key); - $this->assertEquals($keys['keys'][0]['alg'], $key->getAlgorithm()); + $this->assertSame($keys['keys'][0]['alg'], $key->getAlgorithm()); } public function provideFullIntegration() diff --git a/tests/JWKTest.php b/tests/JWKTest.php index b8c24f98..93afea70 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -67,7 +67,7 @@ public function testParsePrivateKeyWithoutAlgWithDefaultAlgParameter() unset($jwkSet['keys'][0]['alg']); $jwks = JWK::parseKeySet($jwkSet, 'foo'); - $this->assertEquals('foo', $jwks['jwk1']->getAlgorithm()); + $this->assertSame('foo', $jwks['jwk1']->getAlgorithm()); } public function testParseKeyWithEmptyDValue() @@ -143,7 +143,7 @@ public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg) $keys = JWK::parseKeySet($jwkSet); $result = JWT::decode($msg, $keys); - $this->assertEquals('foo', $result->sub); + $this->assertSame('foo', $result->sub); } public function provideDecodeByJwkKeySet() @@ -165,6 +165,6 @@ public function testDecodeByMultiJwkKeySet() $result = JWT::decode($msg, self::$keys); - $this->assertEquals('bar', $result->sub); + $this->assertSame('bar', $result->sub); } } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index e3b95144..3ce912ed 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -73,7 +73,7 @@ public function testValidToken() ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); + $this->assertSame($decoded->message, 'abc'); } public function testValidTokenWithLeeway() @@ -85,7 +85,7 @@ public function testValidTokenWithLeeway() ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); + $this->assertSame($decoded->message, 'abc'); JWT::$leeway = 0; } @@ -99,7 +99,7 @@ public function testExpiredTokenWithLeeway() $this->expectException(ExpiredException::class); $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); + $this->assertSame($decoded->message, 'abc'); JWT::$leeway = 0; } @@ -113,7 +113,7 @@ public function testValidTokenWithNbf() ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); + $this->assertSame($decoded->message, 'abc'); } public function testValidTokenWithNbfLeeway() @@ -125,7 +125,7 @@ public function testValidTokenWithNbfLeeway() ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); + $this->assertSame($decoded->message, 'abc'); JWT::$leeway = 0; } @@ -151,7 +151,7 @@ public function testValidTokenWithIatLeeway() ]; $encoded = JWT::encode($payload, 'my_key', 'HS256'); $decoded = JWT::decode($encoded, new Key('my_key', 'HS256')); - $this->assertEquals($decoded->message, 'abc'); + $this->assertSame($decoded->message, 'abc'); JWT::$leeway = 0; } @@ -301,7 +301,7 @@ public function testEdDsaEncodeDecode() $pubKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); $decoded = JWT::decode($msg, new Key($pubKey, 'EdDSA')); - $this->assertEquals('bar', $decoded->foo); + $this->assertSame('bar', $decoded->foo); } public function testInvalidEdDsaEncodeDecode() @@ -350,7 +350,7 @@ public function testDecodesArraysInJWTAsArray() $payload = ['foo' => [1, 2, 3]]; $jwt = JWT::encode($payload, $key, 'HS256'); $decoded = JWT::decode($jwt, new Key($key, 'HS256')); - $this->assertEquals($payload['foo'], $decoded->foo); + $this->assertSame($payload['foo'], $decoded->foo); } /** @@ -367,7 +367,7 @@ public function testEncodeDecode($privateKeyFile, $publicKeyFile, $alg) $publicKey = file_get_contents($publicKeyFile); $decoded = JWT::decode($encoded, new Key($publicKey, $alg)); - $this->assertEquals('bar', $decoded->foo); + $this->assertSame('bar', $decoded->foo); } public function provideEncodeDecode() @@ -393,6 +393,6 @@ public function testEncodeDecodeWithResource() // Verify decoding succeeds $decoded = JWT::decode($encoded, new Key($resource, 'RS512')); - $this->assertEquals('bar', $decoded->foo); + $this->assertSame('bar', $decoded->foo); } } From 78d3ed1073553f7d0bbffa6c2010009a0d483d5c Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 8 Feb 2023 09:14:01 -0500 Subject: [PATCH 41/95] feat: improve caching by only decoding jwks when necessary (#486) --- src/CachedKeySet.php | 45 ++++++++++++++++++---- tests/CachedKeySetTest.php | 77 +++++++++++++++++++++++++++++++++++--- 2 files changed, 109 insertions(+), 13 deletions(-) diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index 87f470d7..baf801f1 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -3,6 +3,7 @@ namespace Firebase\JWT; use ArrayAccess; +use InvalidArgumentException; use LogicException; use OutOfBoundsException; use Psr\Cache\CacheItemInterface; @@ -10,6 +11,7 @@ use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use RuntimeException; +use UnexpectedValueException; /** * @implements ArrayAccess @@ -41,7 +43,7 @@ class CachedKeySet implements ArrayAccess */ private $cacheItem; /** - * @var array + * @var array> */ private $keySet; /** @@ -101,7 +103,7 @@ public function offsetGet($keyId): Key if (!$this->keyIdExists($keyId)) { throw new OutOfBoundsException('Key ID not found'); } - return $this->keySet[$keyId]; + return JWK::parseKey($this->keySet[$keyId], $this->defaultAlg); } /** @@ -130,15 +132,43 @@ public function offsetUnset($offset): void throw new LogicException('Method not implemented'); } + /** + * @return array + */ + private function formatJwksForCache(string $jwks): array + { + $jwks = json_decode($jwks, true); + + if (!isset($jwks['keys'])) { + throw new UnexpectedValueException('"keys" member must exist in the JWK Set'); + } + + if (empty($jwks['keys'])) { + throw new InvalidArgumentException('JWK Set did not contain any keys'); + } + + $keys = []; + foreach ($jwks['keys'] as $k => $v) { + $kid = isset($v['kid']) ? $v['kid'] : $k; + $keys[(string) $kid] = $v; + } + + return $keys; + } + private function keyIdExists(string $keyId): bool { if (null === $this->keySet) { $item = $this->getCacheItem(); // Try to load keys from cache if ($item->isHit()) { - // item found! Return it - $jwks = $item->get(); - $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg); + // item found! retrieve it + $this->keySet = $item->get(); + // If the cached item is a string, the JWKS response was cached (previous behavior). + // Parse this into expected format array instead. + if (\is_string($this->keySet)) { + $this->keySet = $this->formatJwksForCache($this->keySet); + } } } @@ -148,15 +178,14 @@ private function keyIdExists(string $keyId): bool } $request = $this->httpFactory->createRequest('GET', $this->jwksUri); $jwksResponse = $this->httpClient->sendRequest($request); - $jwks = (string) $jwksResponse->getBody(); - $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg); + $this->keySet = $this->formatJwksForCache((string) $jwksResponse->getBody()); if (!isset($this->keySet[$keyId])) { return false; } $item = $this->getCacheItem(); - $item->set($jwks); + $item->set($this->keySet); if ($this->expiresAfter) { $item->expiresAfter($this->expiresAfter); } diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 91d4a27c..04f7994c 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -17,11 +17,12 @@ class CachedKeySetTest extends TestCase private $testJwksUri = 'https://jwk.uri'; private $testJwksUriKey = 'jwkshttpsjwk.uri'; private $testJwks1 = '{"keys": [{"kid":"foo","kty":"RSA","alg":"foo","n":"","e":""}]}'; + private $testCachedJwks1 = ['foo' => ['kid' => 'foo', 'kty' => 'RSA', 'alg' => 'foo', 'n' => '', 'e' => '']]; private $testJwks2 = '{"keys": [{"kid":"bar","kty":"RSA","alg":"bar","n":"","e":""}]}'; private $testJwks3 = '{"keys": [{"kid":"baz","kty":"RSA","n":"","e":""}]}'; private $googleRsaUri = 'https://www.googleapis.com/oauth2/v3/certs'; - // private $googleEcUri = 'https://www.gstatic.com/iap/verify/public_key-jwk'; + private $googleEcUri = 'https://www.gstatic.com/iap/verify/public_key-jwk'; public function testEmptyUriThrowsException() { @@ -117,7 +118,7 @@ public function testKeyIdIsCached() $cacheItem->isHit() ->willReturn(true); $cacheItem->get() - ->willReturn($this->testJwks1); + ->willReturn($this->testCachedJwks1); $cache = $this->prophesize(CacheItemPoolInterface::class); $cache->getItem($this->testJwksUriKey) @@ -136,6 +137,66 @@ public function testKeyIdIsCached() } public function testCachedKeyIdRefresh() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->shouldBeCalledOnce() + ->willReturn(true); + $cacheItem->get() + ->shouldBeCalledOnce() + ->willReturn($this->testCachedJwks1); + $cacheItem->set(Argument::any()) + ->shouldBeCalledOnce() + ->will(function () { + return $this; + }); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem($this->testJwksUriKey) + ->shouldBeCalledOnce() + ->willReturn($cacheItem->reveal()); + $cache->save(Argument::any()) + ->shouldBeCalledOnce() + ->willReturn(true); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->getMockHttpClient($this->testJwks2), // updated JWK + $this->getMockHttpFactory(), + $cache->reveal() + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); + + $this->assertInstanceOf(Key::class, $cachedKeySet['bar']); + $this->assertSame('bar', $cachedKeySet['bar']->getAlgorithm()); + } + + public function testKeyIdIsCachedFromPreviousFormat() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit() + ->willReturn(true); + $cacheItem->get() + ->willReturn($this->testJwks1); + + $cache = $this->prophesize(CacheItemPoolInterface::class); + $cache->getItem($this->testJwksUriKey) + ->willReturn($cacheItem->reveal()); + $cache->save(Argument::any()) + ->willReturn(true); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $this->prophesize(ClientInterface::class)->reveal(), + $this->prophesize(RequestFactoryInterface::class)->reveal(), + $cache->reveal() + ); + $this->assertInstanceOf(Key::class, $cachedKeySet['foo']); + $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm()); + } + + public function testCachedKeyIdRefreshFromPreviousFormat() { $cacheItem = $this->prophesize(CacheItemInterface::class); $cacheItem->isHit() @@ -213,12 +274,18 @@ public function testJwtVerify() $payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')]; $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1'); + // format the cached value to match the expected format + $cachedJwks = []; + $rsaKeySet = file_get_contents(__DIR__ . '/data/rsa-jwkset.json'); + foreach (json_decode($rsaKeySet, true)['keys'] as $k => $v) { + $cachedJwks[$v['kid']] = $v; + } + $cacheItem = $this->prophesize(CacheItemInterface::class); $cacheItem->isHit() ->willReturn(true); $cacheItem->get() - ->willReturn(file_get_contents(__DIR__ . '/data/rsa-jwkset.json') - ); + ->willReturn($cachedJwks); $cache = $this->prophesize(CacheItemPoolInterface::class); $cache->getItem($this->testJwksUriKey) @@ -297,7 +364,7 @@ public function provideFullIntegration() { return [ [$this->googleRsaUri], - // [$this->googleEcUri, 'LYyP2g'] + [$this->googleEcUri, 'LYyP2g'] ]; } From 4dd1e007f22a927ac77da5a3fbb067b42d3bc224 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 9 Feb 2023 13:01:23 -0800 Subject: [PATCH 42/95] chore(main): release 6.4.0 (#477) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d43c9d8c..9242bd30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [6.4.0](https://github.com/firebase/php-jwt/compare/v6.3.2...v6.4.0) (2023-02-08) + + +### Features + +* add support for W3C ES256K ([#462](https://github.com/firebase/php-jwt/issues/462)) ([213924f](https://github.com/firebase/php-jwt/commit/213924f51936291fbbca99158b11bd4ae56c2c95)) +* improve caching by only decoding jwks when necessary ([#486](https://github.com/firebase/php-jwt/issues/486)) ([78d3ed1](https://github.com/firebase/php-jwt/commit/78d3ed1073553f7d0bbffa6c2010009a0d483d5c)) + ## [6.3.2](https://github.com/firebase/php-jwt/compare/v6.3.1...v6.3.2) (2022-11-01) From 3b454f90f147db65a615041dec6661f427d6cb00 Mon Sep 17 00:00:00 2001 From: Akshay Khale Date: Mon, 13 Feb 2023 18:21:14 +0530 Subject: [PATCH 43/95] chore: fix RS256 example in README --- README.md | 49 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ae2b3895..e38752c0 100644 --- a/README.md +++ b/README.md @@ -73,28 +73,43 @@ use Firebase\JWT\Key; $privateKey = << Date: Wed, 5 Apr 2023 23:00:14 +0200 Subject: [PATCH 44/95] chore: add ArrayAccess to PHPDoc for JWT::decode (#443) --- src/JWT.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 269e8caf..b7ca20d1 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -69,11 +69,15 @@ class JWT * Decodes a JWT string into a PHP object. * * @param string $jwt The JWT - * @param Key|array $keyOrKeyArray The Key or associative array of key IDs (kid) to Key objects. - * If the algorithm used is asymmetric, this is the public key - * Each Key object contains an algorithm and matching key. - * Supported algorithms are 'ES384','ES256', 'HS256', 'HS384', - * 'HS512', 'RS256', 'RS384', and 'RS512' + * @param Key|ArrayAccess|array $keyOrKeyArray The Key or associative array of key IDs + * (kid) to Key objects. + * If the algorithm used is asymmetric, this is + * the public key. + * Each Key object contains an algorithm and + * matching key. + * Supported algorithms are 'ES384','ES256', + * 'HS256', 'HS384', 'HS512', 'RS256', 'RS384' + * and 'RS512'. * * @return stdClass The JWT's payload as a PHP object * From 2bc0128b532ebc4c0c9a77cbb95af2bb94a1e20b Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 26 Apr 2023 09:55:25 -0700 Subject: [PATCH 45/95] chore(docs): add multiple keys example to README (#503) --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index e38752c0..86d5d102 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,44 @@ $decoded = JWT::decode($jwt, new Key($publicKey, 'EdDSA')); echo "Decode:\n" . print_r((array) $decoded, true) . "\n"; ```` +Example with multiple keys +-------------------------- +```php +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + +// Example RSA keys from previous example +// $privateKey1 = '...'; +// $publicKey1 = '...'; + +// Example EdDSA keys from previous example +// $privateKey2 = '...'; +// $publicKey2 = '...'; + +$payload = [ + 'iss' => 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +$jwt1 = JWT::encode($payload, $privateKey1, 'RS256', 'kid1'); +$jwt2 = JWT::encode($payload, $privateKey2, 'EdDSA', 'kid2'); +echo "Encode 1:\n" . print_r($jwt1, true) . "\n"; +echo "Encode 2:\n" . print_r($jwt2, true) . "\n"; + +$keys = [ + 'kid1' => new Key($publicKey1, 'RS256'), + 'kid2' => new Key($publicKey2, 'EdDSA'), +]; + +$decoded1 = JWT::decode($jwt1, $keys); +$decoded2 = JWT::decode($jwt2, $keys); + +echo "Decode 1:\n" . print_r((array) $decoded1, true) . "\n"; +echo "Decode 2:\n" . print_r((array) $decoded2, true) . "\n"; +``` + Using JWKs ---------- From d957f8e9956ab94a587fe2bd3334411483088d70 Mon Sep 17 00:00:00 2001 From: Alexey Kopytko Date: Thu, 4 May 2023 06:26:21 +0900 Subject: [PATCH 46/95] chore: skip null checks when input is never null (#502) --- src/JWT.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index b7ca20d1..61524e7b 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -380,7 +380,7 @@ public static function jsonEncode(array $input): string } if ($errno = \json_last_error()) { self::handleJsonError($errno); - } elseif ($json === 'null' && $input !== null) { + } elseif ($json === 'null') { throw new DomainException('Null result with non-null input'); } if ($json === false) { From 7970104a2cfd3228eee7c2cda5a1914c62e99ded Mon Sep 17 00:00:00 2001 From: Saransh Dhingra Date: Thu, 4 May 2023 06:22:43 +0530 Subject: [PATCH 47/95] chore(docs): example of unsafe header decode in README (#501) --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 86d5d102..f0382667 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,40 @@ $decoded_array = (array) $decoded; JWT::$leeway = 60; // $leeway in seconds $decoded = JWT::decode($jwt, new Key($key, 'HS256')); ``` +Example encode/decode headers +------- +Decoding the JWT headers without verifying the JWT first is NOT recommended, and is not supported by +this library. This is because without verifying the JWT, the header values could have been tampered with. +Any value pulled from an unverified header should be treated as if it could be any string sent in from an +attacker. If this is something you still want to do in your application for whatever reason, it's possible to +decode the header values manually simply by calling `json_decode` and `base64_decode` on the JWT +header part: +```php +use Firebase\JWT\JWT; + +$key = 'example_key'; +$payload = [ + 'iss' => 'http://example.org', + 'aud' => 'http://example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +$headers = [ + 'x-forwarded-for' => 'www.google.com' +]; + +// Encode headers in the JWT string +$jwt = JWT::encode($payload, $key, 'HS256', null, $headers); + +// Decode headers from the JWT string WITHOUT validation +// **IMPORTANT**: This operation is vulnerable to attacks, as the JWT has not yet been verified. +// These headers could be any value sent by an attacker. +list($headersB64, $payloadB64, $sig) = explode('.', $jwt); +$decoded = json_decode(base64_decode($headersB64), true); + +print_r($decoded); +``` Example with RS256 (openssl) ---------------------------- ```php From 4543842ca47254de7d2d04b8771a8528690cb00a Mon Sep 17 00:00:00 2001 From: Ajumal Date: Tue, 9 May 2023 20:36:06 +0530 Subject: [PATCH 48/95] fix: Allow KID index 0 --- src/JWT.php | 2 +- tests/JWTTest.php | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/JWT.php b/src/JWT.php index 61524e7b..421c42c2 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -439,7 +439,7 @@ private static function getKey( return $keyOrKeyArray; } - if (empty($kid)) { + if (!isset($kid)) { throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); } diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 3ce912ed..a5721d98 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -204,10 +204,11 @@ public function testEmptyKeyFails() public function testKIDChooser() { $keys = [ - '1' => new Key('my_key', 'HS256'), + '0' => new Key('my_key0', 'HS256'), + '1' => new Key('my_key1', 'HS256'), '2' => new Key('my_key2', 'HS256') ]; - $msg = JWT::encode(['message' => 'abc'], $keys['1']->getKeyMaterial(), 'HS256', '1'); + $msg = JWT::encode(['message' => 'abc'], $keys['0']->getKeyMaterial(), 'HS256', '0'); $decoded = JWT::decode($msg, $keys); $expected = new stdClass(); $expected->message = 'abc'; @@ -217,10 +218,11 @@ public function testKIDChooser() public function testArrayAccessKIDChooser() { $keys = new ArrayObject([ - '1' => new Key('my_key', 'HS256'), + '0' => new Key('my_key0', 'HS256'), + '1' => new Key('my_key1', 'HS256'), '2' => new Key('my_key2', 'HS256'), ]); - $msg = JWT::encode(['message' => 'abc'], $keys['1']->getKeyMaterial(), 'HS256', '1'); + $msg = JWT::encode(['message' => 'abc'], $keys['0']->getKeyMaterial(), 'HS256', '0'); $decoded = JWT::decode($msg, $keys); $expected = new stdClass(); $expected->message = 'abc'; From be6eb589e86b0f8ca9e050765b3375dfab5bf91e Mon Sep 17 00:00:00 2001 From: Ajumal Date: Wed, 10 May 2023 18:08:43 +0530 Subject: [PATCH 49/95] fix: Allow KID index 0 --- src/JWT.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index 421c42c2..c83ff099 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -439,7 +439,7 @@ private static function getKey( return $keyOrKeyArray; } - if (!isset($kid)) { + if (empty($kid) && $kid !== '0') { throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); } From 2ba554439eaedb8b8199b665a8d0cf75204cddc5 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 12 May 2023 09:11:51 -0600 Subject: [PATCH 50/95] chore: drop support for PHP 7.3 (#495) --- .github/workflows/tests.yml | 2 +- composer.json | 6 +++--- tests/CachedKeySetTest.php | 3 +++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6956aa95..b29c8018 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2" ] + php: [ "7.4", "8.0", "8.1", "8.2" ] name: PHP ${{matrix.php }} Unit Test steps: - uses: actions/checkout@v2 diff --git a/composer.json b/composer.json index c9aa3dbb..e23dfe37 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ ], "license": "BSD-3-Clause", "require": { - "php": "^7.1||^8.0" + "php": "^7.4||^8.0" }, "suggest": { "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", @@ -33,8 +33,8 @@ }, "require-dev": { "guzzlehttp/guzzle": "^6.5||^7.4", - "phpspec/prophecy-phpunit": "^1.1", - "phpunit/phpunit": "^7.5||^9.5", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", "psr/cache": "^1.0||^2.0", "psr/http-client": "^1.0", "psr/http-factory": "^1.0" diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 04f7994c..9142fda6 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -6,6 +6,7 @@ use OutOfBoundsException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Http\Client\ClientInterface; @@ -14,6 +15,8 @@ class CachedKeySetTest extends TestCase { + use ProphecyTrait; + private $testJwksUri = 'https://jwk.uri'; private $testJwksUriKey = 'jwkshttpsjwk.uri'; private $testJwks1 = '{"keys": [{"kid":"foo","kty":"RSA","alg":"foo","n":"","e":""}]}'; From 52cd98075cc2b53e780e0fd4e82a0f91f0dab25f Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 12 May 2023 08:44:11 -0700 Subject: [PATCH 51/95] chore: release 6.5.0 Release-As: 6.5.0 From e94e7353302b0c11ec3cfff7180cd0b1743975d2 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Fri, 12 May 2023 08:47:07 -0700 Subject: [PATCH 52/95] chore(main): release 6.5.0 (#506) --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9242bd30..35e97fe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [6.5.0](https://github.com/firebase/php-jwt/compare/v6.4.0...v6.5.0) (2023-05-12) + + +### Bug Fixes + +* allow KID of '0' ([#505](https://github.com/firebase/php-jwt/issues/505)) ([9dc46a9](https://github.com/firebase/php-jwt/commit/9dc46a9c3e5801294249cfd2554c5363c9f9326a)) + + +### Miscellaneous Chores + +* drop support for PHP 7.3 ([#495](https://github.com/firebase/php-jwt/issues/495)) + ## [6.4.0](https://github.com/firebase/php-jwt/compare/v6.3.2...v6.4.0) (2023-02-08) From 6c8f5e7c7ca5a584c23878bc180b6927191422fb Mon Sep 17 00:00:00 2001 From: Sachin Bahukhandi Date: Fri, 12 May 2023 23:46:34 +0530 Subject: [PATCH 53/95] chore(docs): add missing imports for example in README (#507) --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f0382667..5b07aa7d 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,8 @@ Example encode/decode headers Decoding the JWT headers without verifying the JWT first is NOT recommended, and is not supported by this library. This is because without verifying the JWT, the header values could have been tampered with. Any value pulled from an unverified header should be treated as if it could be any string sent in from an -attacker. If this is something you still want to do in your application for whatever reason, it's possible to -decode the header values manually simply by calling `json_decode` and `base64_decode` on the JWT +attacker. If this is something you still want to do in your application for whatever reason, it's possible to +decode the header values manually simply by calling `json_decode` and `base64_decode` on the JWT header part: ```php use Firebase\JWT\JWT; @@ -373,6 +373,8 @@ All exceptions in the `Firebase\JWT` namespace extend `UnexpectedValueException` like this: ```php +use Firebase\JWT\JWT; +use UnexpectedValueException; try { $decoded = JWT::decode($payload, $keys); } catch (LogicException $e) { From 398ccd25ea12fa84b9e4f1085d5ff448c21ec797 Mon Sep 17 00:00:00 2001 From: croensch Date: Tue, 23 May 2023 15:57:20 +0200 Subject: [PATCH 54/95] fix: only check iat if nbf is not used (#493) --- src/JWT.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JWT.php b/src/JWT.php index c83ff099..7e190a3e 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -156,7 +156,7 @@ public static function decode( // Check that this token has been created before 'now'. This prevents // using tokens that have been created for later use (and haven't // correctly used the nbf claim). - if (isset($payload->iat) && $payload->iat > ($timestamp + static::$leeway)) { + if (!isset($payload->nbf) && isset($payload->iat) && $payload->iat > ($timestamp + static::$leeway)) { throw new BeforeValidException( 'Cannot handle token prior to ' . \date(DateTime::ISO8601, $payload->iat) ); From fb85f47cfaeffdd94faf8defdf07164abcdad6c3 Mon Sep 17 00:00:00 2001 From: Pinchon Karim Date: Tue, 13 Jun 2023 18:35:01 +0200 Subject: [PATCH 55/95] feat: allow get headers when decoding token (#442) Co-authored-by: Vishwaraj Anand Co-authored-by: Brent Shaffer --- README.md | 6 ++++++ src/JWT.php | 7 ++++++- tests/JWTTest.php | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b07aa7d..f2cc5d03 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,14 @@ $payload = [ */ $jwt = JWT::encode($payload, $key, 'HS256'); $decoded = JWT::decode($jwt, new Key($key, 'HS256')); +print_r($decoded); + +// Pass a stdClass in as the third parameter to get the decoded header values +$decoded = JWT::decode($jwt, new Key($key, 'HS256'), $headers = new stdClass()); +print_r($headers); print_r($decoded); +print_r($headers); /* NOTE: This will now be an object instead of an associative array. To get diff --git a/src/JWT.php b/src/JWT.php index 7e190a3e..7ffb9852 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -78,6 +78,7 @@ class JWT * Supported algorithms are 'ES384','ES256', * 'HS256', 'HS384', 'HS512', 'RS256', 'RS384' * and 'RS512'. + * @param stdClass $headers Optional. Populates stdClass with headers. * * @return stdClass The JWT's payload as a PHP object * @@ -94,7 +95,8 @@ class JWT */ public static function decode( string $jwt, - $keyOrKeyArray + $keyOrKeyArray, + stdClass &$headers = null ): stdClass { // Validate JWT $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; @@ -111,6 +113,9 @@ public static function decode( if (null === ($header = static::jsonDecode($headerRaw))) { throw new UnexpectedValueException('Invalid header encoding'); } + if ($headers !== null) { + $headers = $header; + } $payloadRaw = static::urlsafeB64Decode($bodyb64); if (null === ($payload = static::jsonDecode($payloadRaw))) { throw new UnexpectedValueException('Invalid claims encoding'); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index a5721d98..7d49bf04 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -397,4 +397,19 @@ public function testEncodeDecodeWithResource() $this->assertSame('bar', $decoded->foo); } + + public function testGetHeaders() + { + $payload = [ + 'message' => 'abc', + 'exp' => time() + JWT::$leeway + 20, // time in the future + ]; + $headers = new stdClass(); + + $encoded = JWT::encode($payload, 'my_key', 'HS256'); + JWT::decode($encoded, new Key('my_key', 'HS256'), $headers); + + $this->assertEquals($headers->typ, 'JWT'); + $this->assertEquals($headers->alg, 'HS256'); + } } From dacbbfcb979ff545ba262eb8c4d9e95ff0ff2d20 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 13 Jun 2023 11:08:24 -0600 Subject: [PATCH 56/95] chore: update README --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index f2cc5d03..701de23a 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,6 @@ print_r($decoded); $decoded = JWT::decode($jwt, new Key($key, 'HS256'), $headers = new stdClass()); print_r($headers); -print_r($decoded); -print_r($headers); - /* NOTE: This will now be an object instead of an associative array. To get an associative array, you will need to cast it as such: From 5a9cf79b4a2eb347230384648cc7b0d68cd97faa Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 13 Jun 2023 11:11:06 -0600 Subject: [PATCH 57/95] chore(main): release 6.6.0 (#511) --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e97fe8..c74fd131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [6.6.0](https://github.com/firebase/php-jwt/compare/v6.5.0...v6.6.0) (2023-06-13) + + +### Features + +* allow get headers when decoding token ([#442](https://github.com/firebase/php-jwt/issues/442)) ([fb85f47](https://github.com/firebase/php-jwt/commit/fb85f47cfaeffdd94faf8defdf07164abcdad6c3)) + + +### Bug Fixes + +* only check iat if nbf is not used ([#493](https://github.com/firebase/php-jwt/issues/493)) ([398ccd2](https://github.com/firebase/php-jwt/commit/398ccd25ea12fa84b9e4f1085d5ff448c21ec797)) + ## [6.5.0](https://github.com/firebase/php-jwt/compare/v6.4.0...v6.5.0) (2023-05-12) From e53979abae927de916a75b9d239cfda8ce32be2a Mon Sep 17 00:00:00 2001 From: Eduardo Dobay Date: Wed, 14 Jun 2023 12:10:17 -0300 Subject: [PATCH 58/95] feat: add ed25519 support to JWK (public keys) (#452) --- src/JWK.php | 28 +++++++++++++++++++++++++++- src/JWT.php | 21 ++++++++++++++++++--- tests/JWKTest.php | 1 + tests/data/ed25519-jwkset.json | 11 +++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 tests/data/ed25519-jwkset.json diff --git a/src/JWK.php b/src/JWK.php index c7eff8ae..873ab41a 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -31,6 +31,12 @@ class JWK // 'P-521' => '1.3.132.0.35', // Len: 132 (not supported) ]; + // For keys with "kty" equal to "OKP" (Octet Key Pair), the "crv" parameter must contain the key subtype. + // This library supports the following subtypes: + private const OKP_SUBTYPES = [ + 'Ed25519' => true, // RFC 8037 + ]; + /** * Parse a set of JWK keys * @@ -145,8 +151,28 @@ public static function parseKey(array $jwk, string $defaultAlg = null): ?Key $publicKey = self::createPemFromCrvAndXYCoordinates($jwk['crv'], $jwk['x'], $jwk['y']); return new Key($publicKey, $jwk['alg']); + case 'OKP': + if (isset($jwk['d'])) { + // The key is actually a private key + throw new UnexpectedValueException('Key data must be for a public key'); + } + + if (!isset($jwk['crv'])) { + throw new UnexpectedValueException('crv not set'); + } + + if (empty(self::OKP_SUBTYPES[$jwk['crv']])) { + throw new DomainException('Unrecognised or unsupported OKP key subtype'); + } + + if (empty($jwk['x'])) { + throw new UnexpectedValueException('x not set'); + } + + // This library works internally with EdDSA keys (Ed25519) encoded in standard base64. + $publicKey = JWT::convertBase64urlToBase64($jwk['x']); + return new Key($publicKey, $jwk['alg']); default: - // Currently only RSA is supported break; } diff --git a/src/JWT.php b/src/JWT.php index 7ffb9852..56cb9314 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -220,7 +220,7 @@ public static function encode( * * @param string $msg The message to sign * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. - * @param string $alg Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256', + * @param string $alg Supported algorithms are 'EdDSA', 'ES384', 'ES256', 'ES256K', 'HS256', * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' * * @return string An encrypted message @@ -283,7 +283,7 @@ public static function sign( * * @param string $msg The original message (header and body) * @param string $signature The original signature - * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial For HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial For Ed*, ES*, HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey * @param string $alg The algorithm * * @return bool @@ -404,13 +404,28 @@ public static function jsonEncode(array $input): string * @throws InvalidArgumentException invalid base64 characters */ public static function urlsafeB64Decode(string $input): string + { + return \base64_decode(self::convertBase64UrlToBase64($input)); + } + + /** + * Convert a string in the base64url (URL-safe Base64) encoding to standard base64. + * + * @param string $input A Base64 encoded string with URL-safe characters (-_ and no padding) + * + * @return string A Base64 encoded string with standard characters (+/) and padding (=), when + * needed. + * + * @see https://www.rfc-editor.org/rfc/rfc4648 + */ + public static function convertBase64UrlToBase64(string $input): string { $remainder = \strlen($input) % 4; if ($remainder) { $padlen = 4 - $remainder; $input .= \str_repeat('=', $padlen); } - return \base64_decode(\strtr($input, '-_', '+/')); + return \strtr($input, '-_', '+/'); } /** diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 93afea70..4e1b0c67 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -151,6 +151,7 @@ public function provideDecodeByJwkKeySet() return [ ['rsa1-private.pem', 'rsa-jwkset.json', 'RS256'], ['ecdsa256-private.pem', 'ec-jwkset.json', 'ES256'], + ['ed25519-1.sec', 'ed25519-jwkset.json', 'EdDSA'], ]; } diff --git a/tests/data/ed25519-jwkset.json b/tests/data/ed25519-jwkset.json new file mode 100644 index 00000000..186b8e29 --- /dev/null +++ b/tests/data/ed25519-jwkset.json @@ -0,0 +1,11 @@ +{ + "keys": [ + { + "kid": "jwk1", + "alg": "EdDSA", + "kty": "OKP", + "crv": "Ed25519", + "x": "uOSJMhbKSG4V5xUHS7B9YHmVg_1yVd-G-Io6oBFhSfY" + } + ] +} From 71278f20b0a623389beefe87a641d03948a38870 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 14 Jun 2023 08:29:26 -0700 Subject: [PATCH 59/95] chore(main): release 6.7.0 (#518) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c74fd131..ee78825b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [6.7.0](https://github.com/firebase/php-jwt/compare/v6.6.0...v6.7.0) (2023-06-14) + + +### Features + +* add ed25519 support to JWK (public keys) ([#452](https://github.com/firebase/php-jwt/issues/452)) ([e53979a](https://github.com/firebase/php-jwt/commit/e53979abae927de916a75b9d239cfda8ce32be2a)) + ## [6.6.0](https://github.com/firebase/php-jwt/compare/v6.5.0...v6.6.0) (2023-06-13) From 91c39c72b22fc3e1191e574089552c1f2041c718 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 14 Jun 2023 13:26:33 -0600 Subject: [PATCH 60/95] fix: handle invalid http responses (#508) --- src/CachedKeySet.php | 10 ++++++++++ tests/CachedKeySetTest.php | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index baf801f1..ee529f9f 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -178,6 +178,16 @@ private function keyIdExists(string $keyId): bool } $request = $this->httpFactory->createRequest('GET', $this->jwksUri); $jwksResponse = $this->httpClient->sendRequest($request); + if ($jwksResponse->getStatusCode() !== 200) { + throw new UnexpectedValueException( + sprintf('HTTP Error: %d %s for URI "%s"', + $jwksResponse->getStatusCode(), + $jwksResponse->getReasonPhrase(), + $this->jwksUri, + ), + $jwksResponse->getStatusCode() + ); + } $this->keySet = $this->formatJwksForCache((string) $jwksResponse->getBody()); if (!isset($this->keySet[$keyId])) { diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 9142fda6..1e73af6d 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -88,6 +88,37 @@ public function testOutOfBoundsThrowsException() $cachedKeySet['bar']; } + public function testInvalidHttpResponseThrowsException() + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('HTTP Error: 404 URL not found'); + $this->expectExceptionCode(404); + + $body = $this->prophesize('Psr\Http\Message\StreamInterface'); + + $response = $this->prophesize('Psr\Http\Message\ResponseInterface'); + $response->getStatusCode() + ->shouldBeCalled() + ->willReturn(404); + $response->getReasonPhrase() + ->shouldBeCalledTimes(1) + ->willReturn('URL not found'); + + $http = $this->prophesize(ClientInterface::class); + $http->sendRequest(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($response->reveal()); + + $cachedKeySet = new CachedKeySet( + $this->testJwksUri, + $http->reveal(), + $this->getMockHttpFactory(), + $this->getMockEmptyCache() + ); + + isset($cachedKeySet[0]); + } + public function testWithExistingKeyId() { $cachedKeySet = new CachedKeySet( @@ -382,6 +413,9 @@ private function getMockHttpClient($testJwks, int $timesCalled = 1) $response->getBody() ->shouldBeCalledTimes($timesCalled) ->willReturn($body->reveal()); + $response->getStatusCode() + ->shouldBeCalledTimes($timesCalled) + ->willReturn(200); $http = $this->prophesize(ClientInterface::class); $http->sendRequest(Argument::any()) From 15d579a76bf2bef1f043c18c9eabbaec0e6989b6 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 14 Jun 2023 12:28:03 -0700 Subject: [PATCH 61/95] chore(tests): remove unused variable --- tests/CachedKeySetTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/CachedKeySetTest.php b/tests/CachedKeySetTest.php index 1e73af6d..2e4e1f62 100644 --- a/tests/CachedKeySetTest.php +++ b/tests/CachedKeySetTest.php @@ -94,8 +94,6 @@ public function testInvalidHttpResponseThrowsException() $this->expectExceptionMessage('HTTP Error: 404 URL not found'); $this->expectExceptionCode(404); - $body = $this->prophesize('Psr\Http\Message\StreamInterface'); - $response = $this->prophesize('Psr\Http\Message\ResponseInterface'); $response->getStatusCode() ->shouldBeCalled() From 5de4323f4baf4d70bca8663bd87682a69c656c3d Mon Sep 17 00:00:00 2001 From: lleyton Date: Wed, 14 Jun 2023 12:28:31 -0700 Subject: [PATCH 62/95] feat: add support for P-384 curve (#515) --- src/JWK.php | 4 ++-- tests/JWKTest.php | 11 ++++++----- tests/data/ec-jwkset.json | 9 +++++++++ 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/JWK.php b/src/JWK.php index 873ab41a..63fb2484 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -27,7 +27,7 @@ class JWK private const EC_CURVES = [ 'P-256' => '1.2.840.10045.3.1.7', // Len: 64 'secp256k1' => '1.3.132.0.10', // Len: 64 - // 'P-384' => '1.3.132.0.34', // Len: 96 (not yet supported) + 'P-384' => '1.3.132.0.34', // Len: 96 // 'P-521' => '1.3.132.0.35', // Len: 132 (not supported) ]; @@ -182,7 +182,7 @@ public static function parseKey(array $jwk, string $defaultAlg = null): ?Key /** * Converts the EC JWK values to pem format. * - * @param string $crv The EC curve (only P-256 is supported) + * @param string $crv The EC curve (only P-256 & P-384 is supported) * @param string $x The EC x-coordinate * @param string $y The EC y-coordinate * diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 4e1b0c67..01082a40 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -129,11 +129,11 @@ public function testDecodeByJwkKeySetTokenExpired() /** * @dataProvider provideDecodeByJwkKeySet */ - public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg) + public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg, $keyId) { $privKey1 = file_get_contents(__DIR__ . '/data/' . $pemFile); $payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')]; - $msg = JWT::encode($payload, $privKey1, $alg, 'jwk1'); + $msg = JWT::encode($payload, $privKey1, $alg, $keyId); $jwkSet = json_decode( file_get_contents(__DIR__ . '/data/' . $jwkFile), @@ -149,9 +149,10 @@ public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg) public function provideDecodeByJwkKeySet() { return [ - ['rsa1-private.pem', 'rsa-jwkset.json', 'RS256'], - ['ecdsa256-private.pem', 'ec-jwkset.json', 'ES256'], - ['ed25519-1.sec', 'ed25519-jwkset.json', 'EdDSA'], + ['rsa1-private.pem', 'rsa-jwkset.json', 'RS256', 'jwk1'], + ['ecdsa256-private.pem', 'ec-jwkset.json', 'ES256', 'jwk1'], + ['ecdsa384-private.pem', 'ec-jwkset.json', 'ES384', 'jwk4'], + ['ed25519-1.sec', 'ed25519-jwkset.json', 'EdDSA', 'jwk1'], ]; } diff --git a/tests/data/ec-jwkset.json b/tests/data/ec-jwkset.json index 213f68ac..50c5b24e 100644 --- a/tests/data/ec-jwkset.json +++ b/tests/data/ec-jwkset.json @@ -26,6 +26,15 @@ "x": "EFpwNuP322bU3WP1DtJgx67L0CUV1MxNixqPVMH2L9Q", "y": "_fSTbijIJjpsqL16cIEvxxf3MaYMY8MbqEq066yV9ls", "alg": "ES256K" + }, + { + "kty": "EC", + "use": "sig", + "crv": "P-384", + "kid": "jwk4", + "x": "FhXXcyKmWkTkdVbWYYU3dtJqpJ0JmLGftEdNzUEFEKSU5MlnLr_FjcneszvXAqEB", + "y": "M4veJF_dO_zhFk44bh_ELXbp0_nn9QaViVtQpuTvpu29eefx6PfUMqX0K--IS4NQ", + "alg": "ES384" } ] } \ No newline at end of file From 48b0210c51718d682e53210c24d25c5a10a2299b Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 20 Jun 2023 09:45:35 -0700 Subject: [PATCH 63/95] 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 64/95] 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 65/95] 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 66/95] 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 67/95] 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 68/95] 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 69/95] 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 70/95] 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 71/95] 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 72/95] 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 73/95] 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 74/95] 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 75/95] 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 76/95] 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 77/95] 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 78/95] 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 79/95] 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 80/95] 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 81/95] 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 82/95] 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 83/95] 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 84/95] 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 85/95] 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 86/95] 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 87/95] 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 88/95] 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 89/95] 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 90/95] 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 91/95] 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 92/95] 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 93/95] 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 94/95] 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 95/95] 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