From 4e12f64bdc3caae51876092c7add8db1049999c9 Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Fri, 7 Sep 2018 15:24:12 -0700 Subject: [PATCH] Add custom JWKS path and kid check to JWKFetcher + tests --- composer.json | 4 +- phpunit.xml.dist | 7 +- src/Helpers/JWKFetcher.php | 123 +++++++++-- tests/Helpers/JWKFetcherTest.php | 196 ++++++++++++++++++ tests/bootstrap.php | 8 + tests/json/localhost--custom-jwks-json.json | 6 + .../json/localhost--well-known-jwks-json.json | 13 ++ tests/json/test-jwks.json | 24 +++ 8 files changed, 352 insertions(+), 29 deletions(-) create mode 100644 tests/Helpers/JWKFetcherTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/json/localhost--custom-jwks-json.json create mode 100644 tests/json/localhost--well-known-jwks-json.json create mode 100644 tests/json/test-jwks.json diff --git a/composer.json b/composer.json index 64d0ede5..4b6ebc3f 100644 --- a/composer.json +++ b/composer.json @@ -34,8 +34,8 @@ } }, "scripts": { - "test": "SHELL_INTERACTIVE=1 vendor/bin/phpunit --colors=always --verbose ", - "test-ci": "vendor/bin/phpunit --coverage-text --coverage-clover=build/coverage.xml", + "test": "SHELL_INTERACTIVE=1 vendor/bin/phpunit --colors=always --coverage-text", + "test-ci": "vendor/bin/phpunit --colors=always --coverage-clover=build/coverage.xml", "phpcs": "\"vendor/bin/phpcs\"", "phpcs-path": "SHELL_INTERACTIVE=1 ./vendor/bin/phpcs", "phpcbf": "\"vendor/bin/phpcbf\"", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index fc970abf..57fa0a91 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,9 +1,8 @@ + coverage-text="true" + bootstrap="./tests/bootstrap.php"> - + ./tests diff --git a/src/Helpers/JWKFetcher.php b/src/Helpers/JWKFetcher.php index bc7e3fa9..6a263d8c 100644 --- a/src/Helpers/JWKFetcher.php +++ b/src/Helpers/JWKFetcher.php @@ -6,16 +6,23 @@ use Auth0\SDK\Helpers\Cache\CacheHandler; use Auth0\SDK\Helpers\Cache\NoCacheHandler; +/** + * Class JWKFetcher. + * + * @package Auth0\SDK\Helpers + */ class JWKFetcher { /** + * Cache handler or null for no caching. * - * @var CacheHandler|NoCacheHandler + * @var CacheHandler|NoCacheHandler|null */ private $cache = null; /** + * Options for the Guzzle HTTP client. * * @var array */ @@ -24,10 +31,10 @@ class JWKFetcher /** * JWKFetcher constructor. * - * @param CacheHandler|null $cache - * @param array $guzzleOptions + * @param CacheHandler|null $cache Cache handler or null for no caching. + * @param array $guzzleOptions Options for the Guzzle HTTP client. */ - public function __construct(CacheHandler $cache = null, $guzzleOptions = []) + public function __construct(CacheHandler $cache = null, array $guzzleOptions = []) { if ($cache === null) { $cache = new NoCacheHandler(); @@ -38,45 +45,115 @@ public function __construct(CacheHandler $cache = null, $guzzleOptions = []) } /** + * Convert a certificate to PEM format. + * + * @param string $cert X509 certificate to convert to PEM format. * - * @param string $cert * @return string */ protected function convertCertToPem($cert) { - return '-----BEGIN CERTIFICATE-----'.PHP_EOL.chunk_split($cert, 64, PHP_EOL).'-----END CERTIFICATE-----'.PHP_EOL; + $output = '-----BEGIN CERTIFICATE-----'.PHP_EOL; + $output .= chunk_split($cert, 64, PHP_EOL); + $output .= '-----END CERTIFICATE-----'.PHP_EOL; + return $output; } /** + * Fetch x509 cert for RS256 token decoding. * - * @param string $iss + * @param string $domain Base domain for the JWKS, including scheme. + * @param string|null $kid Kid to use. + * @param string $path Path to the JWKS from the $domain. * - * @return array|mixed|null + * @return mixed * - * @throws \Exception + * @throws \Exception If the Guzzle HTTP client cannot complete the request. */ - public function fetchKeys($iss) + public function fetchKeys($domain, $kid = null, $path = '.well-known/jwks.json') { - $url = "{$iss}.well-known/jwks.json"; + $jwks_url = $domain.$path; + + // Check for a cached JWKS value. + $secret = $this->cache->get($jwks_url); + if (! is_null($secret)) { + return $secret; + } + + $secret = []; + + $jwks = $this->getJwks( $domain, $path ); + + if (! is_array( $jwks['keys'] ) || empty( $jwks['keys'] )) { + return $secret; + } - if (($secret = $this->cache->get($url)) === null) { - $secret = []; + // No kid passed so get the kid of the first JWK. + if (is_null($kid)) { + $kid = $this->getProp( $jwks, 'kid' ); + } + + $x5c = $this->getProp( $jwks, 'x5c', $kid ); - $request = new RequestBuilder([ - 'domain' => $iss, - 'basePath' => '.well-known/jwks.json', - 'method' => 'GET', - 'guzzleOptions' => $this->guzzleOptions - ]); - $jwks = $request->call(); + // Need the kid and x5c for a well-formed return value. + if (! is_null($kid) && ! is_null($x5c)) { + $secret[$kid] = $this->convertCertToPem($x5c); + $this->cache->set($jwks_url, $secret); + } + return $secret; + } + + /** + * Get a specific property from a JWKS using a key, if provided. + * + * @param array $jwks JWKS to parse. + * @param string $prop Property to retrieve. + * @param null|string $kid Kid to check. + * + * @return null|string + */ + public function getProp(array $jwks, $prop, $kid = null) + { + $r_key = null; + if (! $kid) { + // No kid indicated, get the first entry. + $r_key = $jwks['keys'][0]; + } else { + // Iterate through the JWKS for the correct kid. foreach ($jwks['keys'] as $key) { - $secret[$key['kid']] = $this->convertCertToPem($key['x5c'][0]); + if (isset($key['kid']) && $key['kid'] === $kid) { + $r_key = $key; + break; + } } + } - $this->cache->set($url, $secret); + // If a key was not found or the property does not exist, return. + if (is_null($r_key) || ! isset($r_key[$prop])) { + return null; } - return $secret; + // If the value is an array, get the first element. + return is_array( $r_key[$prop] ) ? $r_key[$prop][0] : $r_key[$prop]; + } + + /** + * Get a JWKS given a domain and path to call. + * + * @param string $domain Base domain for the JWKS, including scheme. + * @param string $path Path to the JWKS from the $domain. + * + * @return mixed|string + */ + protected function getJwks($domain, $path) + { + $request = new RequestBuilder([ + 'domain' => $domain, + 'basePath' => $path, + 'method' => 'GET', + 'guzzleOptions' => $this->guzzleOptions + ]); + return $request->call(); } } diff --git a/tests/Helpers/JWKFetcherTest.php b/tests/Helpers/JWKFetcherTest.php new file mode 100644 index 00000000..35857eeb --- /dev/null +++ b/tests/Helpers/JWKFetcherTest.php @@ -0,0 +1,196 @@ +getStub(); + + $keys = $jwksFetcher->fetchKeys( 'https://localhost/' ); + + $this->assertCount(1, $keys); + + $pem_parts = $this->getPemParts( $keys ); + + $this->assertEquals( 4, count($pem_parts) ); + $this->assertEquals( '-----BEGIN CERTIFICATE-----', $pem_parts[0] ); + $this->assertEquals( '-----END CERTIFICATE-----', $pem_parts[2] ); + $this->assertEquals( '__test_x5c_1__', $pem_parts[1] ); + } + + /** + * Test that a standard JWKS path returns the correct x5c when given a kid. + * + * @return void + */ + public function testFetchKeysWithKid() + { + $jwksFetcher = $this->getStub(); + + $keys = $jwksFetcher->fetchKeys( 'https://localhost/', '__test_kid_2__' ); + + $this->assertCount(1, $keys); + + $pem_parts = $this->getPemParts( $keys ); + + $this->assertEquals( 4, count($pem_parts) ); + $this->assertEquals( '-----BEGIN CERTIFICATE-----', $pem_parts[0] ); + $this->assertEquals( '-----END CERTIFICATE-----', $pem_parts[2] ); + $this->assertEquals( '__test_x5c_2__', $pem_parts[1] ); + } + + /** + * Test that a custom JWKS path returns the correct JSON. + * + * @return void + */ + public function testFetchKeysWithPath() + { + $jwksFetcher = $this->getStub(); + + $keys = $jwksFetcher->fetchKeys( 'https://localhost/', '__test_custom_kid__', '.custom/jwks.json' ); + + $this->assertCount(1, $keys); + + $pem_parts = $this->getPemParts( $keys ); + + $this->assertEquals( 4, count( $pem_parts ) ); + $this->assertEquals( '-----BEGIN CERTIFICATE-----', $pem_parts[0] ); + $this->assertEquals( '-----END CERTIFICATE-----', $pem_parts[2] ); + $this->assertEquals( '__test_custom_x5c__', $pem_parts[1] ); + } + + /** + * Test that a JWK with no x509 cert returns a blank array. + * + * @return void + */ + public function testFetchKeysNoX5c() + { + $jwksFetcher = $this->getStub(); + + $keys = $jwksFetcher->fetchKeys( 'https://localhost/', '__no_x5c_test_kid_2__' ); + + $this->assertEmpty($keys); + } + + /** + * Test that the protected getProp method returns correctly. + * + * @return void + */ + public function testConvertCertToPem() + { + $class = new \ReflectionClass(JWKFetcher::class); + $method = $class->getMethod('convertCertToPem'); + $method->setAccessible(true); + + $test_string_1 = ''; + for ($i = 1; $i <= 64; $i++) { + $test_string_1 .= 'a'; + } + + $test_string_2 = ''; + for ($i = 1; $i <= 64; $i++) { + $test_string_2 .= 'b'; + } + + $returned_pem = $method->invoke(new JWKFetcher(), $test_string_1.$test_string_2); + $pem_parts = explode( PHP_EOL, $returned_pem ); + $this->assertEquals( 5, count($pem_parts) ); + $this->assertEquals( '-----BEGIN CERTIFICATE-----', $pem_parts[0] ); + $this->assertEquals( $test_string_1, $pem_parts[1] ); + $this->assertEquals( $test_string_2, $pem_parts[2] ); + $this->assertEquals( '-----END CERTIFICATE-----', $pem_parts[3] ); + } + + /** + * Test that the protected getProp method returns correctly. + * + * @return void + */ + public function testGetProp() + { + $jwks = $this->getLocalJwks( 'test', '-jwks' ); + + $jwksFetcher = new JWKFetcher(); + + $this->assertEquals( '__string_value_1__', $jwksFetcher->getProp( $jwks, 'string' ) ); + $this->assertEquals( '__array_value_1__', $jwksFetcher->getProp( $jwks, 'array' ) ); + $this->assertNull( $jwksFetcher->getProp( $jwks, 'invalid' ) ); + + $test_kid = '__kid_value__'; + + $this->assertEquals( '__string_value_2__', $jwksFetcher->getProp( $jwks, 'string', $test_kid ) ); + $this->assertEquals( '__array_value_3__', $jwksFetcher->getProp( $jwks, 'array', $test_kid ) ); + $this->assertNull( $jwksFetcher->getProp( $jwks, 'invalid', $test_kid ) ); + } + + /** + * Get a test JSON fixture instead of a remote one. + * + * @param string $domain Domain name of the JWKS. + * @param string $path Path to the JWKS. + * + * @return array + */ + public function getLocalJwks($domain = '', $path = '') + { + // Normalize the domain to a file name. + $domain = str_replace( 'https://', '', $domain ); + $domain = str_replace( 'http://', '', $domain ); + + // Replace everything that isn't a letter, digit, or dash. + $pattern = '/[^a-zA-Z1-9^-]/i'; + $file_append = preg_replace($pattern, '-', $domain).preg_replace($pattern, '-', $path); + + // Get the test JSON file. + $json_contents = file_get_contents( AUTH0_PHP_TEST_JSON_DIR.$file_append.'.json' ); + return json_decode( $json_contents, true ); + } + + /** + * Stub the JWKFetcher class. + * + * @return \PHPUnit_Framework_MockObject_MockObject + */ + private function getStub() + { + $stub = $this->getMockBuilder(JWKFetcher::class) + ->setMethods(['getJwks']) + ->getMock(); + + $stub->method('getJwks') + ->will($this->returnCallback([$this, 'getLocalJwks'])); + + return $stub; + } + + /** + * Get array of PEM parts. + * + * @param array $keys JWKS keys. + * + * @return array + */ + private function getPemParts(array $keys) + { + $keys_keys = array_keys($keys); + $kid = $keys_keys[0]; + $pem = $keys[$kid]; + return explode( PHP_EOL, $pem ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..89115849 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,8 @@ +