Skip to content

Commit

Permalink
Add signing key rotation and custom JWKS URI support
Browse files Browse the repository at this point in the history
  • Loading branch information
joshcanhelp committed Feb 7, 2020
1 parent 55c1e3b commit 6837b51
Show file tree
Hide file tree
Showing 5 changed files with 82 additions and 25 deletions.
17 changes: 13 additions & 4 deletions src/Auth0.php
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,13 @@ class Auth0
*/
protected $idTokenLeeway;

/**
* URI to the JWKS when accepting RS256 ID tokens.
*
* @var string
*/
protected $jwksUri;

/**
* Maximum time allowed between authentication and ID token verification.
*
Expand Down Expand Up @@ -225,9 +232,10 @@ class Auth0
* - max_age (Integer) Optional. Maximum time allowed between authentication and callback
* - id_token_alg (String) Optional. ID token algorithm expected; RS256 (default) or HS256 only
* - id_token_leeway (Integer) Optional. Leeway, in seconds, for ID token validation.
* - jwks_uri (String) Optional. URI to the JWKS when accepting RS256 ID tokens.
* - store (Mixed) Optional. StorageInterface for identity and token persistence;
* leave empty to default to SessionStore
* - transient_store (Mixed) Optional. StorageInterface for transient auth data;
* - transient_store (Mixed) Optional. StorageInterface for transient auth data;
* leave empty to default to CookieStore
* - cache_handler (Mixed) Optional. CacheInterface instance or false for none
* - persist_user (Boolean) Optional. Persist the user info, default true
Expand Down Expand Up @@ -268,6 +276,7 @@ public function __construct(array $config)
$this->skipUserinfo = $config['skip_userinfo'] ?? true;
$this->maxAge = $config['max_age'] ?? null;
$this->idTokenLeeway = $config['id_token_leeway'] ?? null;
$this->jwksUri = $config['jwks_uri'] ?? 'https://'.$this->domain.'/.well-known/jwks.json';

$this->idTokenAlg = $config['id_token_alg'] ?? 'RS256';
if (! in_array( $this->idTokenAlg, ['HS256', 'RS256'] )) {
Expand Down Expand Up @@ -647,9 +656,9 @@ public function decodeIdToken(string $idToken, array $verifierOptions = []) : ar
$idTokenIss = 'https://'.$this->domain.'/';
$sigVerifier = null;
if ('RS256' === $this->idTokenAlg) {
$jwksFetcher = new JWKFetcher($this->cacheHandler, $this->guzzleOptions);
$jwks = $jwksFetcher->getKeys($idTokenIss.'.well-known/jwks.json');
$sigVerifier = new AsymmetricVerifier($jwks);
$jwksHttpOptions = array_merge( [ 'jwks_uri' => $this->jwksUri ], $this->guzzleOptions );
$jwksFetcher = new JWKFetcher($this->cacheHandler, $jwksHttpOptions);
$sigVerifier = new AsymmetricVerifier($jwksFetcher);
} else if ('HS256' === $this->idTokenAlg) {
$sigVerifier = new SymmetricVerifier($this->clientSecret);
}
Expand Down
59 changes: 50 additions & 9 deletions src/Helpers/JWKFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
*/
class JWKFetcher
{
/**
* How long should the cache persist? Set to 10 minutes.
*
* @see https://www.php-fig.org/psr/psr-16/#12-definitions
*/
const CACHE_TTL = 600;

/**
* Cache handler or null for no caching.
Expand All @@ -29,22 +35,31 @@ class JWKFetcher
*
* @var array
*/
private $guzzleOptions;
private $httpOptions;

/**
* JWKS URI to use, set in $httpOptions['jwks_uri'].
*
* @var array
*/
private $jwksUri;

/**
* JWKFetcher constructor.
*
* @param CacheInterface|null $cache Cache handler or null for no caching.
* @param array $guzzleOptions Options for the Guzzle HTTP client.
* @param CacheInterface|null $cache Cache handler or null for no caching.
* @param array $httOptions Options for the Guzzle HTTP client.
*/
public function __construct(CacheInterface $cache = null, array $guzzleOptions = [])
public function __construct(CacheInterface $cache = null, array $httOptions = [])
{
if ($cache === null) {
$cache = new NoCacheHandler();
}

$this->cache = $cache;
$this->guzzleOptions = $guzzleOptions;
$this->cache = $cache;
$this->httpOptions = $httOptions;
$this->jwksUri = $this->httpOptions['jwks_uri'] ?? null;
unset( $this->httpOptions['jwks_uri'] );
}

/**
Expand All @@ -62,15 +77,41 @@ protected function convertCertToPem(string $cert) : string
return $output;
}

/**
* Get a specific kid from a JWKS.
*
* @param string $kid Key ID to get.
* @param string|null $jwksUri JWKS URI to use, or fallback on class-level one.
*
* @return mixed|null
*/
public function getKey(string $kid, string $jwksUri = null)
{
$jwksUri = $jwksUri ?? $this->jwksUri;
$keys = $this->getKeys( $jwksUri );

if (! empty( $keys ) && empty( $keys[$kid] )) {
$this->cache->delete( md5( $jwksUri ) );
$keys = $this->getKeys( $jwksUri );
}

return $keys[$kid] ?? null;
}

/**
* Gets an array of keys from the JWKS as kid => x5c.
*
* @param string $jwks_url Full URL to the JWKS.
*
* @return array
*/
public function getKeys(string $jwks_url) : array
public function getKeys(string $jwks_url = null) : array
{
$jwks_url = $jwks_url ?? $this->jwksUri;
if (empty( $jwks_url )) {
return [];
}

$cache_key = md5($jwks_url);
$keys = $this->cache->get($cache_key);
if (is_array($keys) && ! empty($keys)) {
Expand All @@ -92,7 +133,7 @@ public function getKeys(string $jwks_url) : array
$keys[$key['kid']] = $this->convertCertToPem( $key['x5c'][0] );
}

$this->cache->set($cache_key, $keys);
$this->cache->set($cache_key, $keys, self::CACHE_TTL);
return $keys;
}

Expand All @@ -111,7 +152,7 @@ protected function requestJwks(string $jwks_url) : array
$request = new RequestBuilder([
'domain' => $jwks_url,
'method' => 'GET',
'guzzleOptions' => $this->guzzleOptions
'guzzleOptions' => $this->httpOptions
]);
return $request->call();
}
Expand Down
16 changes: 9 additions & 7 deletions src/Helpers/Tokens/AsymmetricVerifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
namespace Auth0\SDK\Helpers\Tokens;

use Auth0\SDK\Exception\InvalidTokenException;
use Auth0\SDK\Helpers\JWKFetcher;
use Lcobucci\JWT\Signer\Key;
use Lcobucci\JWT\Signer\Rsa\Sha256 as RsSigner;
use Lcobucci\JWT\Token;
Expand All @@ -17,18 +18,18 @@ final class AsymmetricVerifier extends SignatureVerifier
{

/**
* JWKS array with kid as keys, PEM cert as values.
* Array of kid => keys or a JWKFetcher instance.
*
* @var array
* @var array|JWKFetcher
*/
private $jwks;

/**
* JwksVerifier constructor.
*
* @param array $jwks JWKS to use.
* @param array|JWKFetcher $jwks Array of kid => keys or a JWKFetcher instance.
*/
public function __construct(array $jwks)
public function __construct($jwks)
{
$this->jwks = $jwks;
parent::__construct('RS256');
Expand All @@ -45,11 +46,12 @@ public function __construct(array $jwks)
*/
protected function checkSignature(Token $token) : bool
{
$tokenKid = $token->getHeader('kid', false);
if (! array_key_exists($tokenKid, $this->jwks)) {
$tokenKid = $token->getHeader('kid', false);
$signingKey = is_array( $this->jwks ) ? ($this->jwks[$tokenKid] ?? null) : $this->jwks->getKey( $tokenKid );
if (! $signingKey) {
throw new InvalidTokenException( 'ID token key ID "'.$tokenKid.'" was not found in the JWKS' );
}

return $token->verify(new RsSigner(), new Key($this->jwks[$tokenKid]));
return $token->verify(new RsSigner(), new Key($signingKey));
}
}
12 changes: 8 additions & 4 deletions tests/Auth0Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Auth0\SDK\Exception\CoreException;
use Auth0\SDK\Exception\InvalidTokenException;
use Auth0\SDK\Store\SessionStore;
use Auth0\Tests\Helpers\Tokens\AsymmetricVerifierTest;
use Auth0\Tests\Helpers\Tokens\SymmetricVerifierTest;
use Auth0\Tests\Traits\ErrorHelpers;
use Cache\Adapter\PHPArray\ArrayCachePool;
Expand Down Expand Up @@ -657,7 +658,7 @@ public function testThatCacheHandlerCanBeSet()
{
$request_history = [];
$mock = new MockHandler([
new Response( 200, [ 'Content-Type' => 'application/json' ], '{"keys":[{"kid":"abc","x5c":["123"]}]}' ),
new Response( 200, [ 'Content-Type' => 'application/json' ], '{"keys":[{"kid":"__test_kid__","x5c":["123"]}]}' ),
]);
$handler = HandlerStack::create($mock);
$handler->push( Middleware::history($request_history) );
Expand All @@ -668,21 +669,24 @@ public function testThatCacheHandlerCanBeSet()
'client_id' => uniqid(),
'redirect_uri' => uniqid(),
'cache_handler' => $pool,
'transient_store' => new SessionStore(),
'guzzle_options' => [
'handler' => $handler
]
]);
$_SESSION['auth0__nonce'] = '__test_nonce__';

try {
@$auth0->setIdToken(uniqid());
@$auth0->setIdToken(AsymmetricVerifierTest::getToken());
} catch ( \Exception $e ) {
// ...
}

$stored_jwks = $pool->get(md5('https://test.auth0.com/.well-known/jwks.json'));

$this->assertArrayHasKey('abc', $stored_jwks);
$this->assertEquals("-----BEGIN CERTIFICATE-----\n123\n-----END CERTIFICATE-----\n", $stored_jwks['abc']);
$this->assertNotEmpty($stored_jwks);
$this->assertArrayHasKey('__test_kid__', $stored_jwks);
$this->assertEquals("-----BEGIN CERTIFICATE-----\n123\n-----END CERTIFICATE-----\n", $stored_jwks['__test_kid__']);
}

/*
Expand Down
3 changes: 2 additions & 1 deletion tests/Helpers/Tokens/AsymmetricVerifierTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,9 @@ public static function getTokenBuilder() : Builder
return (new Builder())->withClaim('sub', '__test_sub__')->withHeader('kid', '__test_kid__');
}

public static function getToken(string $rsa_private_key, Builder $builder = null) : Token
public static function getToken(string $rsa_private_key = null, Builder $builder = null) : Token
{
$rsa_private_key = $rsa_private_key ?? self::getRsaKeys()['private'];
$builder = $builder ?? self::getTokenBuilder();
return $builder->getToken( new RsSigner(), new Key( $rsa_private_key ));
}
Expand Down

0 comments on commit 6837b51

Please sign in to comment.