Skip to content

Commit

Permalink
Add custom JWKS path and kid check to JWKFetcher + tests
Browse files Browse the repository at this point in the history
  • Loading branch information
joshcanhelp committed Sep 7, 2018
1 parent ef641b0 commit d1decae
Show file tree
Hide file tree
Showing 8 changed files with 335 additions and 29 deletions.
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand Down
7 changes: 3 additions & 4 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
<phpunit
colors="true"
bootstrap="./vendor/autoload.php"
>
coverage-text="true"
bootstrap="./tests/bootstrap.php">
<testsuites>
<testsuite name="Auth0 Test Suite">
<testsuite name="Auth0 PHP SDK Test Suite">
<directory>./tests</directory>
</testsuite>
</testsuites>
Expand Down
121 changes: 98 additions & 23 deletions src/Helpers/JWKFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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();
Expand All @@ -38,45 +45,113 @@ 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 = [];
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();
if (! is_null($kid) && ! is_null($kid)) {
$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();
}
}
182 changes: 182 additions & 0 deletions tests/Helpers/JWKFetcherTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<?php
namespace Auth0\Tests\Helpers\Cache;

use \Auth0\SDK\Helpers\JWKFetcher;

/**
* Class JWKFetcherTest.
*
* @package Auth0\Tests\Helpers\Cache
*/
class JWKFetcherTest extends \PHPUnit_Framework_TestCase
{
/**
* Test that a standard JWKS path returns the first x5c.
*
* @return void
*/
public function testFetchKeysWithoutKid()
{
$jwksFetcher = $this->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 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 );
}
}
5 changes: 5 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
require_once '../vendor/autoload.php';
$bootstrap_dir = dirname(__FILE__);

define( 'AUTH0_PHP_TEST_JSON_DIR', $bootstrap_dir.'/json/' );
8 changes: 8 additions & 0 deletions tests/json/localhost--custom-jwks-json.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{"keys":
[
{
"x5c": [ "__test_custom_x5c__" ],
"kid": "__test_custom_kid__"
}
]
}
Loading

0 comments on commit d1decae

Please sign in to comment.