From dc09c99e851072bc79c9e6d8359147858cedbfc6 Mon Sep 17 00:00:00 2001 From: Eric Tendian Date: Thu, 27 Feb 2020 20:21:31 -0600 Subject: [PATCH] feat: Add JWK support --- src/JWK.php | 171 ++++++++++++++++++++++++++++++++++++++++++++++ tests/JWKTest.php | 159 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 src/JWK.php create mode 100644 tests/JWKTest.php diff --git a/src/JWK.php b/src/JWK.php new file mode 100644 index 00000000..f2777df8 --- /dev/null +++ b/src/JWK.php @@ -0,0 +1,171 @@ + + * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD + * @link https://github.com/firebase/php-jwt + */ +class JWK +{ + /** + * Parse a set of JWK keys + * + * @param array $jwks The JSON Web Key Set as an associative array + * + * @return array An associative array that represents the set of keys + * + * @throws InvalidArgumentException Provided JWK Set is empty + * @throws UnexpectedValueException Provided JWK Set was invalid + * @throws DomainException OpenSSL failure + * + * @uses parseKey + */ + public static function parseKeySet(array $jwks) + { + $keys = array(); + + 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'); + } + + foreach ($jwks['keys'] as $k => $v) { + $kid = isset($v['kid']) ? $v['kid'] : $k; + if ($key = self::parseKey($v)) { + $keys[$kid] = $key; + } + } + + if (0 === count($keys)) { + throw new UnexpectedValueException('No supported algorithms found in JWK Set'); + } + + return $keys; + } + + /** + * Parse a JWK key + * + * @param array $jwk An individual JWK + * + * @return resource|array An associative array that represents the key + * + * @throws InvalidArgumentException Provided JWK is empty + * @throws UnexpectedValueException Provided JWK was invalid + * @throws DomainException OpenSSL failure + * + * @uses createPemFromModulusAndExponent + */ + private static function parseKey(array $jwk) + { + if (empty($jwk)) { + throw new InvalidArgumentException('JWK must not be empty'); + } + if (!isset($jwk['kty'])) { + throw new UnexpectedValueException('JWK must contain a "kty" parameter'); + } + + switch ($jwk['kty']) { + case 'RSA': + if (array_key_exists('d', $jwk)) { + throw new UnexpectedValueException('RSA private keys are not supported'); + } + if (!isset($jwk['n']) || !isset($jwk['e'])) { + throw new UnexpectedValueException('RSA keys must contain values for both "n" and "e"'); + } + + $pem = self::createPemFromModulusAndExponent($jwk['n'], $jwk['e']); + $publicKey = openssl_pkey_get_public($pem); + if (false === $publicKey) { + throw new DomainException( + 'OpenSSL error: ' . openssl_error_string() + ); + } + return $publicKey; + default: + // Currently only RSA is supported + break; + } + } + + /** + * Create a public key represented in PEM format from RSA modulus and exponent information + * + * @param string $n The RSA modulus encoded in Base64 + * @param string $e The RSA exponent encoded in Base64 + * + * @return string The RSA public key represented in PEM format + * + * @uses encodeLength + */ + private static function createPemFromModulusAndExponent($n, $e) + { + $modulus = JWT::urlsafeB64Decode($n); + $publicExponent = JWT::urlsafeB64Decode($e); + + $components = array( + '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*', + 48, + self::encodeLength(strlen($components['modulus']) + strlen($components['publicExponent'])), + $components['modulus'], + $components['publicExponent'] + ); + + // sequence(oid(1.2.840.113549.1.1.1), null)) = rsaEncryption. + $rsaOID = pack('H*', '300d06092a864886f70d0101010500'); // hex version of MA0GCSqGSIb3DQEBAQUA + $rsaPublicKey = chr(0) . $rsaPublicKey; + $rsaPublicKey = chr(3) . self::encodeLength(strlen($rsaPublicKey)) . $rsaPublicKey; + + $rsaPublicKey = pack( + 'Ca*a*', + 48, + self::encodeLength(strlen($rsaOID . $rsaPublicKey)), + $rsaOID . $rsaPublicKey + ); + + $rsaPublicKey = "-----BEGIN PUBLIC KEY-----\r\n" . + chunk_split(base64_encode($rsaPublicKey), 64) . + '-----END PUBLIC KEY-----'; + + return $rsaPublicKey; + } + + /** + * DER-encode the length + * + * DER supports lengths up to (2**8)**127, however, we'll only support lengths up to (2**8)**4. See + * {@link http://itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf#p=13 X.690 paragraph 8.1.3} for more information. + * + * @param int $length + * @return string + */ + private static function encodeLength($length) + { + if ($length <= 0x7F) { + return chr($length); + } + + $temp = ltrim(pack('N', $length), chr(0)); + + return pack('Ca*', 0x80 | strlen($temp), $temp); + } +} diff --git a/tests/JWKTest.php b/tests/JWKTest.php new file mode 100644 index 00000000..4f8fdf65 --- /dev/null +++ b/tests/JWKTest.php @@ -0,0 +1,159 @@ +expectException($exceptionName); + } else { + parent::setExpectedException($exceptionName, $message, $code); + } + } + + public function testDecodeByJWKKeySetTokenExpired() + { + $jsKey = array( + 'kty' => 'RSA', + 'e' => 'AQAB', + 'use' => 'sig', + 'kid' => 's1', + 'n' => 'kWp2zRA23Z3vTL4uoe8kTFptxBVFunIoP4t_8TDYJrOb7D1iZNDXVeEsYKp6ppmrTZDAgd-cNOTKLd4M39WJc5FN0maTAVKJc7NxklDeKc4dMe1BGvTZNG4MpWBo-taKULlYUu0ltYJuLzOjIrTHfarucrGoRWqM0sl3z2-fv9k', + ); + + $key = JWK::parseKeySet(array('keys' => array($jsKey))); + + $header = array( + 'kid' => 's1', + 'alg' => 'RS256', + ); + $payload = array ( + 'scp' => array ('openid', 'email', 'profile', 'aas'), + 'sub' => 'tUCYtnfIBPWcrSJf4yBfvN1kww4KGcy3LIPk1GVzsE0', + 'clm' => array ('!5v8H'), + 'iss' => 'http://130.211.243.114:8080/c2id', + 'exp' => 1441126539, + 'uip' => array('groups' => array('admin', 'audit')), + 'cid' => 'pk-oidc-01', + ); + $signature = 'PvYrnf3k1Z0wgRwCgq0WXKaoIv1hHtzBFO5cGfCs6bl4suc6ilwCWmJqRxGYkU2fNTGyMOt3OUnnBEwl6v5qN6jv7zbkVAVKVvbQLxhHC2nXe3izvoCiVaMEH6hE7VTWwnPbX_qO72mCwTizHTJTZGLOsyXLYM6ctdOMf7sFPTI'; + $msg = sprintf('%s.%s.%s', + JWT::urlsafeB64Encode(json_encode($header)), + JWT::urlsafeB64Encode(json_encode($payload)), + $signature + ); + + $this->setExpectedException('Firebase\JWT\ExpiredException'); + + JWT::decode($msg, $key, array('RS256')); + } + + public function testDecodeByJWKKeySet() + { + $jsKey = array( + 'kty' => 'RSA', + 'e' => 'AQAB', + 'use' => 'sig', + 'kid' => 's1', + 'n' => 'kWp2zRA23Z3vTL4uoe8kTFptxBVFunIoP4t_8TDYJrOb7D1iZNDXVeEsYKp6ppmrTZDAgd-cNOTKLd4M39WJc5FN0maTAVKJc7NxklDeKc4dMe1BGvTZNG4MpWBo-taKULlYUu0ltYJuLzOjIrTHfarucrGoRWqM0sl3z2-fv9k', + ); + + $key = JWK::parseKeySet(array('keys' => array($jsKey))); + + $header = array( + 'kid' => 's1', + 'alg' => 'RS256', + ); + $payload = array ( + 'scp' => array ('openid', 'email', 'profile', 'aas'), + 'sub' => 'tUCYtnfIBPWcrSJf4yBfvN1kww4KGcy3LIPk1GVzsE0', + 'clm' => array ('!5v8H'), + 'iss' => 'http://130.211.243.114:8080/c2id', + 'exp' => 1441126539, + 'uip' => array('groups' => array('admin', 'audit')), + 'cid' => 'pk-oidc-01', + ); + $signature = 'PvYrnf3k1Z0wgRwCgq0WXKaoIv1hHtzBFO5cGfCs6bl4suc6ilwCWmJqRxGYkU2fNTGyMOt3OUnnBEwl6v5qN6jv7zbkVAVKVvbQLxhHC2nXe3izvoCiVaMEH6hE7VTWwnPbX_qO72mCwTizHTJTZGLOsyXLYM6ctdOMf7sFPTI'; + $msg = sprintf('%s.%s.%s', + JWT::urlsafeB64Encode(json_encode($header)), + JWT::urlsafeB64Encode(json_encode($payload)), + $signature + ); + + $this->setExpectedException('Firebase\JWT\ExpiredException'); + + $payload = JWT::decode($msg, $key, array('RS256')); + + $this->assertEquals("tUCYtnfIBPWcrSJf4yBfvN1kww4KGcy3LIPk1GVzsE0", $payload->sub); + $this->assertEquals(1441126539, $payload->exp); + } + + public function testDecodeByMultiJWKKeySet() + { + $jsKey1 = array( + 'kty' => 'RSA', + 'e' => 'AQAB', + 'use' => 'sig', + 'kid' => 'CXup', + 'n' => 'hrwD-lc-IwzwidCANmy4qsiZk11yp9kHykOuP0yOnwi36VomYTQVEzZXgh2sDJpGgAutdQudgwLoV8tVSsTG9SQHgJjH9Pd_9V4Ab6PANyZNG6DSeiq1QfiFlEP6Obt0JbRB3W7X2vkxOVaNoWrYskZodxU2V0ogeVL_LkcCGAyNu2jdx3j0DjJatNVk7ystNxb9RfHhJGgpiIkO5S3QiSIVhbBKaJHcZHPF1vq9g0JMGuUCI-OTSVg6XBkTLEGw1C_R73WD_oVEBfdXbXnLukoLHBS11p3OxU7f4rfxA_f_72_UwmWGJnsqS3iahbms3FkvqoL9x_Vj3GhuJSf97Q', + ); + $jsKey2 = array( + 'kty' => 'EC', + 'use' => 'sig', + 'crv' => 'P-256', + 'kid' => 'yGvt', + 'x' => 'pvgdqM3RCshljmuCF1D2Ez1w5ei5k7-bpimWLPNeEHI', + 'y' => 'JSmUhbUTqiFclVLEdw6dz038F7Whw4URobjXbAReDuM', + ); + $jsKey3 = array( + 'kty' => 'EC', + 'use' => 'sig', + 'crv' => 'P-384', + 'kid' => '9nHY', + 'x' => 'JPKhjhE0Bj579Mgj3Cn3ERGA8fKVYoGOaV9BPKhtnEobphf8w4GSeigMesL-038W', + 'y' => 'UbJa1QRX7fo9LxSlh7FOH5ABT5lEtiQeQUcX9BW0bpJFlEVGqwec80tYLdOIl59M', + ); + $jsKey4 = array( + 'kty' => 'EC', + 'use' => 'sig', + 'crv' => 'P-521', + 'kid' => 'tVzS', + 'x' => 'AZgkRHlIyNQJlPIwTWdHqouw41k9dS3GJO04BDEnJnd_Dd1owlCn9SMXA-JuXINn4slwbG4wcECbctXb2cvdGtmn', + 'y' => 'AdBC6N9lpupzfzcIY3JLIuc8y8MnzV-ItmzHQcC5lYWMTbuM9NU_FlvINeVo8g6i4YZms2xFB-B0VVdaoF9kUswC', + ); + + $key = JWK::parseKeySet(array('keys' => array($jsKey1, $jsKey2, $jsKey3, $jsKey4))); + + $header = array( + 'kid' => 'CXup', + 'alg' => 'RS256', + ); + $payload = array( + 'sub' => 'f8b67cc46030777efd8bce6c1bfe29c6c0f818ec', + 'scp' => array('openid', 'name', 'profile', 'picture', 'email', 'rs-pk-main', 'rs-pk-so', 'rs-pk-issue', 'rs-pk-web'), + 'clm' => array('!5v8H'), + 'iss' => 'https://id.projectkit.net/authenticate', + 'exp' => 1492228336, + 'iat' => 1491364336, + 'cid' => 'cid-pk-web', + ); + $signature = 'KW1K-72bMtiNwvyYBgffG6VaG6I59cELGYQR8M2q7HA8dmzliu6QREJrqyPtwW_rDJZbsD3eylvkRinK9tlsMXCOfEJbxLdAC9b4LKOsnsbuXXwsJHWkFG0a7osdW0ZpXJDoMFlO1aosxRGMkaqhf1wIkvQ5PM_EB08LJv7oz64Antn5bYaoajwgvJRl7ChatRDn9Sx5UIElKD1BK4Uw5WdrZwBlWdWZVNCSFhy4F6SdZvi3OBlXzluDwq61RC-pl2iivilJNljYWVrthHDS1xdtaVz4oteHW13-IS7NNEz6PVnzo5nyoPWMAB4JlRnxcfOFTTUqOA2mX5Csg0UpdQ'; + $msg = sprintf('%s.%s.%s', + JWT::urlsafeB64Encode(json_encode($header)), + JWT::urlsafeB64Encode(json_encode($payload)), + $signature + ); + + $this->setExpectedException('Firebase\JWT\ExpiredException'); + + $payload = JWT::decode($msg, $key, array('RS256')); + + $this->assertEquals("f8b67cc46030777efd8bce6c1bfe29c6c0f818ec", $payload->sub); + $this->assertEquals(1492228336, $payload->exp); + } +}