From a9c487b0e877f7b86b9ec5f529c34a2cb55d9ec8 Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Wed, 5 Sep 2018 16:39:04 -0700 Subject: [PATCH 1/6] Add ID token validation --- src/Auth0.php | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src/Auth0.php b/src/Auth0.php index c05a02a5..fe222cfa 100644 --- a/src/Auth0.php +++ b/src/Auth0.php @@ -16,6 +16,7 @@ use Auth0\SDK\API\Helpers\State\StateHandler; use Auth0\SDK\API\Helpers\State\SessionStateHandler; use Auth0\SDK\API\Helpers\State\DummyStateHandler; +use Firebase\JWT\JWT; /** * Class Auth0 @@ -71,6 +72,13 @@ class Auth0 */ protected $clientSecret; + /** + * Is the Auth0 Client Secret encoded? + * + * @var boolean + */ + protected $clientSecretEncoded; + /** * Response mode * @@ -225,10 +233,11 @@ public function __construct(array $config) throw new CoreException('Invalid redirect_uri'); } - $this->domain = $config['domain']; - $this->clientId = $config['client_id']; - $this->clientSecret = $config['client_secret']; - $this->redirectUri = $config['redirect_uri']; + $this->domain = $config['domain']; + $this->clientId = $config['client_id']; + $this->clientSecret = $config['client_secret']; + $this->clientSecretEncoded = ! empty( $config['secret_base64_encoded'] ); + $this->redirectUri = $config['redirect_uri']; if (isset($config['audience'])) { $this->audience = $config['audience']; @@ -557,9 +566,35 @@ public function setAccessToken($accessToken) * @param string $idToken - ID token returned from the code exchange. * * @return \Auth0\SDK\Auth0 + * + * @throws CoreException If ID token did not validate. */ public function setIdToken($idToken) { + try { + $issuer = 'https://'.$this->domain.'/'; + $jwtVerifier = new JWTVerifier([ + 'guzzle_options' => $this->guzzleOptions, + 'client_secret' => $this->clientSecret, + 'secret_base64_encoded' => $this->clientSecretEncoded, + 'authorized_iss' => [ $issuer ], + 'supported_algs' => [ 'HS256', 'RS256' ], + 'valid_audiences' => [ + $this->clientId, + $issuer, + $issuer.'userinfo' + ], + ]); + $jwtVerifier->verifyAndDecode( $idToken ); + } catch (\Exception $e) { + $message = 'ID token could not be validated.'; + if ($e->getMessage()) { + $message .= ' '.$e->getMessage(); + } + + throw new CoreException($message); + } + if (in_array('id_token', $this->persistantMap)) { $this->store->set('id_token', $idToken); } From d00be636c8663a64fa1d149cd32917fec7544b36 Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Thu, 27 Sep 2018 15:31:05 -0700 Subject: [PATCH 2/6] Refactor ID token verification and add tests --- composer.json | 2 - src/API/Helpers/TokenGenerator.php | 98 ++---- src/Auth0.php | 83 ++++-- src/JWTVerifier.php | 296 ++++++++++++++----- tests/API/ApiTests.php | 22 +- tests/API/Helpers/TokenTest.php | 460 +++++++++++++++++++++++++---- tests/traits/ErrorHelpers.php | 19 ++ 7 files changed, 724 insertions(+), 256 deletions(-) create mode 100644 tests/traits/ErrorHelpers.php diff --git a/composer.json b/composer.json index 4b6ebc3f..be23bfa1 100644 --- a/composer.json +++ b/composer.json @@ -37,9 +37,7 @@ "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\"", - "phpcbf-path": "SHELL_INTERACTIVE=1 ./vendor/bin/phpcbf", "sniffs": "\"vendor/bin/phpcs\" -e", "config-phpcs": "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin::run" } diff --git a/src/API/Helpers/TokenGenerator.php b/src/API/Helpers/TokenGenerator.php index b3acdbc9..ee3de645 100644 --- a/src/API/Helpers/TokenGenerator.php +++ b/src/API/Helpers/TokenGenerator.php @@ -2,103 +2,67 @@ use Firebase\JWT\JWT; +/** + * Class TokenGenerator. + * Generates HS256 ID tokens. + * + * @package Auth0\SDK\API\Helpers + */ class TokenGenerator { - /** - * - * @var string + * Default token expiration time. */ - protected $client_id; + const DEFAULT_LIFETIME = 3600; /** + * Client ID for the token. * * @var string */ - protected $client_secret; + protected $client_id; /** + * Client Secret for the token. * * @var string */ - protected $secret_base64_encoded; - - /** - * TokenGenerator Constructor. - * - * Configuration: - * - client_id (String) Required. The id of the application, you can get this in the - * auth0 console - * - client_secret (String) Required. The application secret, same comment as above - * - secret_base64_encoded (Bool) Required. Is the secret Base64 encoded? - * - * @param array $credentials - */ - public function __construct($credentials) - { - if (! isset($credentials['secret_base64_encoded'])) { - $credentials['secret_base64_encoded'] = true; - } - - $this->client_id = $credentials['client_id']; - $this->client_secret = $credentials['client_secret']; - $this->secret_base64_encoded = $credentials['secret_base64_encoded']; - } + protected $client_secret; /** + * TokenGenerator constructor. * - * @param string $input - * @return string + * @param string $client_id Client ID to use. + * @param string $client_secret Client Secret to use. */ - protected function bstr2bin($input) + public function __construct($client_id, $client_secret) { - // Unpack as a hexadecimal string - $value = $this->str2hex($input); - - // Output binary representation - return base_convert($value, 16, 2); + $this->client_id = $client_id; + $this->client_secret = $client_secret; } /** + * Create the ID token. * - * @param string $input - * @return mixed - */ - protected function str2hex($input) - { - $data = unpack('H*', $input); - return $data[1]; - } - - /** + * @param array $scopes Array of scopes to include. + * @param integer $lifetime Lifetime of the token. + * @param boolean $secret_encoded True to base64 decode the client secret. * - * @param $scopes - * @param integer $lifetime * @return string */ - public function generate($scopes, $lifetime = 36000) + public function generate(array $scopes, $lifetime = self::DEFAULT_LIFETIME, $secret_encoded = true) { - $time = time(); - - $payload = [ + $time = time(); + $payload = [ 'iat' => $time, - 'scopes' => $scopes + 'scopes' => $scopes, + 'exp' => $time + $lifetime, + 'aud' => $this->client_id, ]; + $payload['jti'] = md5(json_encode($payload)); - $jti = md5(json_encode($payload)); - - $payload['jti'] = $jti; - $payload['exp'] = $time + $lifetime; - $payload['aud'] = $this->client_id; - - if ($this->secret_base64_encoded) { - $secret = base64_decode(strtr($this->client_secret, '-_', '+/')); - } else { - $secret = $this->client_secret; - } - - $jwt = JWT::encode($payload, $secret); + $secret = $secret_encoded ? base64_decode(strtr($this->client_secret, '-_', '+/')) : $this->client_secret; - return $jwt; + return JWT::encode($payload, $secret); } } diff --git a/src/Auth0.php b/src/Auth0.php index fe222cfa..9f9c2dae 100644 --- a/src/Auth0.php +++ b/src/Auth0.php @@ -181,6 +181,27 @@ class Auth0 */ protected $guzzleOptions; + /** + * Algorithm to use for ID token validation. + * + * @var string + */ + protected $idTokenAlg = null; + + /** + * Valid audiences for ID tokens. + * + * @var array + */ + protected $idTokenAud = []; + + /** + * Valid issuer(s) for ID tokens. + * + * @var array + */ + protected $idTokenIss = []; + /** * State Handler. * @@ -259,6 +280,33 @@ public function __construct(array $config) $this->guzzleOptions = $config['guzzle_options']; } + // If a token algorithm is passed, make sure it's a specific string. + if (! empty($config['id_token_alg'])) { + if (! in_array( $config['id_token_alg'], ['HS256', 'RS256'] )) { + throw new CoreException('Invalid id_token_alg; must be "HS256" or "RS256"'); + } + + $this->idTokenAlg = $config['id_token_alg']; + } + + // If a token audience is passed, make sure it's an array. + if (! empty($config['id_token_aud'])) { + if (! is_array( $config['id_token_aud'] )) { + throw new CoreException('Invalid id_token_aud; must be an array of string values'); + } + + $this->idTokenAud = $config['id_token_aud']; + } + + // If a token issuer is passed, make sure it's an array. + if (! empty($config['id_token_iss'])) { + if (! is_array( $config['id_token_iss'] )) { + throw new CoreException('Invalid id_token_iss; must be an array of string values'); + } + + $this->idTokenIss = $config['id_token_iss']; + } + $this->debugMode = isset($config['debug']) ? $config['debug'] : false; // User info is persisted by default. @@ -567,33 +615,20 @@ public function setAccessToken($accessToken) * * @return \Auth0\SDK\Auth0 * - * @throws CoreException If ID token did not validate. + * @throws CoreException + * @throws Exception\InvalidTokenException */ public function setIdToken($idToken) { - try { - $issuer = 'https://'.$this->domain.'/'; - $jwtVerifier = new JWTVerifier([ - 'guzzle_options' => $this->guzzleOptions, - 'client_secret' => $this->clientSecret, - 'secret_base64_encoded' => $this->clientSecretEncoded, - 'authorized_iss' => [ $issuer ], - 'supported_algs' => [ 'HS256', 'RS256' ], - 'valid_audiences' => [ - $this->clientId, - $issuer, - $issuer.'userinfo' - ], - ]); - $jwtVerifier->verifyAndDecode( $idToken ); - } catch (\Exception $e) { - $message = 'ID token could not be validated.'; - if ($e->getMessage()) { - $message .= ' '.$e->getMessage(); - } - - throw new CoreException($message); - } + $jwtVerifier = new JWTVerifier([ + 'valid_audiences' => ! empty($this->idTokenAud) ? $this->idTokenAud : [ $this->clientId ], + 'supported_algs' => $this->idTokenAlg ? [ $this->idTokenAlg ] : [ 'HS256', 'RS256' ], + 'authorized_iss' => $this->idTokenIss ? $this->idTokenIss : [ 'https://'.$this->domain.'/' ], + 'client_secret' => $this->clientSecret, + 'secret_base64_encoded' => $this->clientSecretEncoded, + 'guzzle_options' => $this->guzzleOptions, + ]); + $jwtVerifier->verifyAndDecode( $idToken ); if (in_array('id_token', $this->persistantMap)) { $this->store->set('id_token', $idToken); diff --git a/src/JWTVerifier.php b/src/JWTVerifier.php index 73218435..7b81aa68 100644 --- a/src/JWTVerifier.php +++ b/src/JWTVerifier.php @@ -5,160 +5,294 @@ use Auth0\SDK\Exception\CoreException; use Auth0\SDK\Exception\InvalidTokenException; +use Auth0\SDK\Helpers\Cache\CacheHandler; use Auth0\SDK\Helpers\JWKFetcher; use Firebase\JWT\JWT; +/** + * Class JWTVerifier. + * Used to validate JWTs issued by Auth0. + * + * @package Auth0\SDK + */ class JWTVerifier { + /** + * Instance of JWKFetcher, injected or instantiated with this class's config options. + * + * @var JWKFetcher|null + */ protected $JWKFetcher = null; - protected $supported_algs = null; + /** + * Algorithms supported. + * Only pass in the expected algorithm. + * + * @var array + */ + protected $supported_algs = ['HS256']; - protected $valid_audiences = null; + /** + * Audiences expected in the token. + * + * @var array + */ + protected $valid_audiences; + /** + * Authorized issuing domain. + * Required for RS256 tokens. + * + * @var array|null + */ protected $authorized_iss = null; + /** + * Application Client Secret. + * Required for HS256 tokens. + * + * @var string|null + */ protected $client_secret = null; - protected $secret_base64_encoded = null; - + /** + * Path to the JWKS for RS256 tokens. + * + * @var string + */ protected $jwks_path = '.well-known/jwks.json'; /** * JWTVerifier Constructor. * - * Configuration: - * - cache (CacheHandler) - Optional. Instance of CacheHandler to cache the JWKs. - * - supported_algs (Array) - Optional. The list of supported algorithms. By default only HS256. - * - client_secret (String) - Required (if supported HS256). The Auth0 application secret. - * - valid_audiences (Array) - Required. The list of audiences accepted by the service. - * - authorized_iss (Array) - Required (if supported RS256). The list of issuers trusted by the service. - * - guzzle_options (Array) - Optional. Extra configuration options sent to the Guzzle HTTP client. + * @param array $config Uses the following keys: + * - valid_audiences (Array) - Required; list of audiences accepted by the service. + * - client_secret (String) - Required for HS256; Auth0 Application Client Secret. + * - authorized_iss (Array) - Required for RS256; list of issuers trusted by the service. + * - cache (CacheHandler) - Optional. Instance of CacheHandler to cache the JWKs. + * - supported_algs (Array) - List of supported algorithms; defaults to HS256. + * - guzzle_options (Array) - Extra configuration options sent to the Guzzle HTTP client. + * - jwks_path (string) - Path from the issuer domain to the JWKS; used for RS256. + * @param JWKFetcher|null $jwkFetcher Instance of the JWKFetcher class to inject or null to instantiate. * - * @param array $config - * @throws CoreException + * @throws CoreException If the suported_algs config key is set. + * @throws CoreException If the valid_audiences config key is empty. + * @throws CoreException If the token supports RS256 and the authorized_iss config key is empty. + * @throws CoreException If the the token supports HS256 and the client_secret config key is empty. */ - public function __construct($config) + public function __construct(array $config, JWKFetcher $jwkFetcher = null) { $cache = null; $guzzleOptions = []; - if (isset($config['cache'])) { - $cache = $config['cache']; + // Allow for dependency injection of a JWKFetcher object. + $this->JWKFetcher = $jwkFetcher; + if (! $this->JWKFetcher instanceof JWKFetcher) { + // CacheHandler implementation to be used in JWKFetcher. + if (isset($config['cache']) && $config['cache'] instanceof CacheHandler) { + $cache = $config['cache']; + } + + // Pass in Guzzle client options, if present. + if (isset($config['guzzle_options']) && is_array($config['guzzle_options'])) { + $guzzleOptions = $config['guzzle_options']; + } + + $this->JWKFetcher = new JWKFetcher($cache, $guzzleOptions); } - if (isset($config['guzzle_options'])) { - $guzzleOptions = $config['guzzle_options']; + // JWKS path to use; see variable declaration above for default. + if (isset($config['jwks_path'])) { + $this->jwks_path = (string) $config['jwks_path']; } + // Legacy misspelling in JWT library. if (isset($config['suported_algs'])) { throw new CoreException('`suported_algs` was properly renamed to `supported_algs`.'); } - if (! isset($config['supported_algs'])) { - $config['supported_algs'] = ['HS256']; + // Make sure we have audiences to check. + if (empty($config['valid_audiences'])) { + throw new CoreException('The audience is mandatory'); } - if (! isset($config['secret_base64_encoded'])) { - $config['secret_base64_encoded'] = true; - } + $this->valid_audiences = $config['valid_audiences']; - if (! isset($config['valid_audiences'])) { - throw new CoreException('The audience is mandatory'); + // Set the supported algorithms if passed; see variable declaration above for default. + if (isset($config['supported_algs'])) { + $this->supported_algs = $config['supported_algs']; } - if (! isset($config['authorized_iss'])) { - if (in_array('RS256', $config['supported_algs'])) { - throw new CoreException('The iss is mandatory when accepting RS256 signed tokens'); - } else { - $config['authorized_iss'] = []; - } + // Check for algorithms that are not HS256 or RS256. + $unsupported_algs = array_diff( $this->supported_algs, [ 'HS256', 'RS256' ] ); + if (! empty( $unsupported_algs )) { + throw new CoreException( + 'Cannot support the following algorithm(s): '.implode( ', ', $unsupported_algs ).'.' + ); } - if (in_array('HS256', $config['supported_algs']) && ! isset($config['client_secret'])) { - throw new CoreException('The client_secret is mandatory when accepting HS256 signed tokens'); + // Set the authorized issuer is passed. + if (isset( $config['authorized_iss'] )) { + $this->authorized_iss = $config['authorized_iss']; } - $this->supported_algs = $config['supported_algs']; - $this->valid_audiences = $config['valid_audiences']; - $this->authorized_iss = $config['authorized_iss']; - $this->secret_base64_encoded = $config['secret_base64_encoded']; - - if (in_array('HS256', $config['supported_algs'])) { - $this->client_secret = $config['client_secret']; + // Make sure we have an issuer if the token is RS256. + if (empty($this->authorized_iss) && $this->supportsAlg( 'RS256' )) { + throw new CoreException('The iss is required when accepting RS256 signed tokens.'); } - if (isset($config['jwks_path'])) { - $this->jwks_path = (string) $config['jwks_path']; - } + // Only store the client_secret is this is an HS256 token. + if ($this->supportsAlg( 'HS256' )) { + // HS256 tokens require a client_secret. + if (empty($config['client_secret'])) { + throw new CoreException('The client_secret is required when accepting HS256 signed tokens.'); + } - $this->JWKFetcher = new JWKFetcher($cache, $guzzleOptions); + if (! isset($config['secret_base64_encoded']) || $config['secret_base64_encoded']) { + // If secret_base64_encoded is not passed or it is passed as truth-y, decode the client secret. + $this->client_secret = $this->decodeB64($config['client_secret']); + } else { + // Otherwise, leave as-is. + $this->client_secret = $config['client_secret']; + } + } } /** + * Verify and decode a JWT. * - * @param $jwt + * @param string $jwt JWT to verify and decode. * * @return mixed * - * @throws CoreException - * @throws InvalidTokenException - * @throws \Exception + * @throws InvalidTokenException If the token does not have 3 sections. + * @throws InvalidTokenException If the token does not have an algorithm or that algorithm is not supported. + * @throws InvalidTokenException If the token does not have a valid audience. + * @throws CoreException If an RS256 token is missing a key ID. + * @throws CoreException If an RS256 token does not have a valid issuer. + * @throws CoreException If the token cannot be decoded. */ public function verifyAndDecode($jwt) { $tks = explode('.', $jwt); - if (count($tks) != 3) { + + if (count($tks) !== 3) { throw new InvalidTokenException('Wrong number of segments'); } - $headb64 = $tks[0]; - $body64 = $tks[1]; - $head = json_decode(JWT::urlsafeB64Decode($headb64)); + try { + $head_decoded = $this->decodeTokenSegment($tks[0]); + $body_decoded = $this->decodeTokenSegment($tks[1]); + } catch (\DomainException $e) { + throw new InvalidTokenException('Malformed token.'); + } - if (! is_object($head) || ! isset($head->alg)) { - throw new InvalidTokenException('Invalid token'); + if (! is_object($head_decoded)) { + throw new InvalidTokenException('Malformed token head.'); } - if (! in_array($head->alg, $this->supported_algs)) { - throw new InvalidTokenException('Invalid signature algorithm'); + if (empty($head_decoded->alg)) { + throw new InvalidTokenException('Token algorithm not found.'); } - if ($head->alg === 'RS256') { - $body = json_decode(JWT::urlsafeB64Decode($body64)); - if (! in_array($body->iss, $this->authorized_iss)) { - throw new CoreException("We can't trust on a token issued by: `{$body->iss}`."); - } + if (! $this->supportsAlg($head_decoded->alg)) { + throw new InvalidTokenException('Token algorithm not supported.'); + } - $jwks_url = $body->iss.$this->jwks_path; - $secret = $this->JWKFetcher->fetchKeys($jwks_url); - } else if ($head->alg === 'HS256') { - if ($this->secret_base64_encoded) { - $secret = JWT::urlsafeB64Decode($this->client_secret); - } else { - $secret = $this->client_secret; - } - } else { - throw new InvalidTokenException('Invalid signature algorithm'); + if (! is_object($body_decoded)) { + throw new InvalidTokenException('Malformed token payload.'); } - try { - // Decode the user - $decodedToken = JWT::decode($jwt, $secret, ['HS256', 'RS256']); - // validate that this JWT was made for us - $audience = $decodedToken->aud; - if (! is_array($audience)) { - $audience = [$audience]; + if (empty($body_decoded->aud)) { + throw new InvalidTokenException('Token audience not found.'); + } + + // Validate the token audience. + $audience = $body_decoded->aud; + if (! is_array($audience)) { + $audience = [$audience]; + }; + if (! count(array_intersect($audience, $this->valid_audiences))) { + throw new InvalidTokenException('This token is not intended for us.'); + } + + if ('HS256' === $head_decoded->alg) { + $secret = $this->client_secret; + } else { + if (empty($head_decoded->kid)) { + throw new CoreException('Token key ID is missing for RS256 token.'); } - if (count(array_intersect($audience, $this->valid_audiences)) == 0) { - throw new InvalidTokenException('This token is not intended for us.'); + if (empty($body_decoded->iss) || ! in_array($body_decoded->iss, $this->authorized_iss)) { + throw new CoreException('We cannot trust on a token issued by `'.$body_decoded->iss.'`.'); } + + $jwks_url = $body_decoded->iss.$this->jwks_path; + $secret[$head_decoded->kid] = $this->JWKFetcher->requestJwkX5c($jwks_url, $head_decoded->kid); + } + + try { + return $this->decodeToken($jwt, $secret); } catch (\Exception $e) { throw new CoreException($e->getMessage()); } + } + + /** + * Wrapper for JWT::decode(). + * + * @param string $jwt JWT to decode. + * @param string|array $secret Secret to use. + * + * @return mixed + * + * @codeCoverageIgnore + */ + protected function decodeToken($jwt, $secret) + { + return JWT::decode($jwt, $secret, $this->supported_algs); + } - return $decodedToken; + /** + * Base64 decode a string. + * + * @param string $encoded Base64 encoded string. + * + * @return string + * + * @codeCoverageIgnore + */ + private function decodeB64($encoded) + { + return JWT::urlsafeB64Decode($encoded); + } + + /** + * Base64 and JSON decode a string. + * + * @param string $segment Base64 encoded JSON string. + * + * @return object + * + * @codeCoverageIgnore + */ + private function decodeTokenSegment($segment) + { + return JWT::jsonDecode(JWT::urlsafeB64Decode($segment)); + } + + /** + * Check whether the $alg parameter is supported. + * + * @param string $alg Algorithm to check. + * + * @return boolean + * + * @codeCoverageIgnore + */ + private function supportsAlg($alg) + { + return in_array( $alg, $this->supported_algs ); } } diff --git a/tests/API/ApiTests.php b/tests/API/ApiTests.php index 19d5b2cf..77bf67dc 100644 --- a/tests/API/ApiTests.php +++ b/tests/API/ApiTests.php @@ -1,12 +1,14 @@ $env['GLOBAL_CLIENT_ID'], - 'client_secret' => $env['GLOBAL_CLIENT_SECRET'] - ] - ); + $generator = new TokenGenerator( $env['GLOBAL_CLIENT_ID'], $env['GLOBAL_CLIENT_SECRET'] ); return $generator->generate($scopes); } @@ -69,17 +66,4 @@ protected static function getApiStatic($endpoint, array $actions) $api_client = new Management($token, self::$env['DOMAIN']); return $api_client->$endpoint; } - - /** - * Does an error message contain a specific string? - * - * @param \Exception $e - Error object. - * @param string $str - String to find in the error message. - * - * @return boolean - */ - protected function errorHasString(\Exception $e, $str) - { - return ! (false === strpos($e->getMessage(), $str)); - } } diff --git a/tests/API/Helpers/TokenTest.php b/tests/API/Helpers/TokenTest.php index 52c058b1..268eb739 100644 --- a/tests/API/Helpers/TokenTest.php +++ b/tests/API/Helpers/TokenTest.php @@ -4,100 +4,434 @@ use Auth0\SDK\API\Helpers\TokenGenerator; use Auth0\SDK\JWTVerifier; use Auth0\SDK\Auth0JWT; +use Auth0\SDK\Exception\CoreException; +use Auth0\SDK\Exception\InvalidTokenException; +use Auth0\Tests\Traits\ErrorHelpers; +use Auth0\SDK\Helpers\JWKFetcher; +use Firebase\JWT\JWT; +/** + * Class TokenTest + * + * @package Auth0\Tests\Api\Helpers + */ class TokenTest extends \PHPUnit_Framework_TestCase { - public function testTokenGenerationDecode() + use ErrorHelpers; + + /** + * Default Client ID. + */ + const CLIENT_ID = '__test_client_id__'; + + /** + * Default Client Secret. + */ + const CLIENT_SECRET = '__test_client_secret__'; + + /** + * Test legacy misspelling. + * + * @return void + */ + public function testThatSuportedAlgsThrowsException() { - $client_id = 'client_id_1'; - $client_secret = 'client_secret_1'; + $caught_exception = false; + try { + new JWTVerifier( [ 'suported_algs' => uniqid() ] ); + } catch (CoreException $e) { + $caught_exception = $this->errorHasString( $e, '`suported_algs` was properly renamed to `supported_algs`' ); + } - $generator = new TokenGenerator([ 'client_id' => $client_id, 'client_secret' => $client_secret]); + $this->assertTrue($caught_exception); + } - $jwt = $generator->generate( - [ - 'users' => [ - 'actions' => ['read'] - ] - ] - ); + /** + * Test for audience config param. + * + * @return void + */ + public function testThatMissingAudThrowsException() + { + $caught_exception = false; + try { + new JWTVerifier([]); + } catch (CoreException $e) { + $caught_exception = $this->errorHasString( $e, 'The audience is mandatory' ); + } - $verifier = new JWTVerifier( - [ - 'valid_audiences' => [$client_id], - 'client_secret' => $client_secret - ] - ); + $this->assertTrue($caught_exception); + } + + /** + * Test unsupported algorithms in the config param. + * + * @return void + */ + public function testThatOtherAlgsThrowsException() + { + $caught_exception = false; + try { + new JWTVerifier( [ + 'valid_audiences' => [ uniqid() ], + 'supported_algs' => [ 'RS256', 'RS512' ], + ] ); + } catch (CoreException $e) { + $caught_exception = $this->errorHasString( $e, 'Cannot support the following algorithm(s): RS512' ); + } + + $this->assertTrue($caught_exception); + } + + /** + * Test for missing issuer for RS256 tokens. + * + * @return void + */ + public function testThatMissingIssThrowsException() + { + $caught_exception = false; + try { + new JWTVerifier( [ + 'valid_audiences' => [ uniqid() ], + 'supported_algs' => [ 'RS256' ], + ] ); + } catch (CoreException $e) { + $caught_exception = $this->errorHasString( $e, 'The iss is required' ); + } + + $this->assertTrue($caught_exception); + } + + /** + * Test that a secret is required for an HS256 token. + * + * @return void + */ + public function testThatMissingSecretThrowsException() + { + $caught_exception = false; + try { + new JWTVerifier( [ + 'valid_audiences' => [ uniqid() ], + 'supported_algs' => [ 'HS256' ], + ] ); + } catch (CoreException $e) { + $caught_exception = $this->errorHasString($e, 'The client_secret is required'); + } + + $this->assertTrue($caught_exception); + } + + /** + * Test that a malformed token is rejected. + * + * @return void + * + * @throws CoreException See Auth0\SDK\JWTVerifier::verifyAndDecode(). + */ + public function testThatTokenWithIncorrectSegmentsThrowsException() + { + $verifier = new JWTVerifier( [ + 'valid_audiences' => [ uniqid() ], + 'supported_algs' => [ 'HS256' ], + 'client_secret' => self::CLIENT_SECRET, + ] ); + + $caught_exception = false; + try { + $verifier->verifyAndDecode( uniqid() ); + } catch (InvalidTokenException $e) { + $caught_exception = $this->errorHasString($e, 'Wrong number of segments'); + } + + $this->assertTrue($caught_exception); + + $caught_exception = false; + try { + $verifier->verifyAndDecode( uniqid().'.'.uniqid() ); + } catch (InvalidTokenException $e) { + $caught_exception = $this->errorHasString($e, 'Wrong number of segments'); + } + + $this->assertTrue($caught_exception); + } + + /** + * Test that a malformed token or missing algorithm fails. + * + * @return void + * + * @throws CoreException See Auth0\SDK\JWTVerifier::verifyAndDecode(). + */ + public function testThatTokenWithBadAlgThrowsException() + { + $dummy_segment = JWT::urlsafeB64Encode('{"dummy":"yes"}'); + $verifier = new JWTVerifier( [ + 'valid_audiences' => [ uniqid() ], + 'supported_algs' => [ 'HS256' ], + 'client_secret' => self::CLIENT_SECRET, + ] ); - $decoded = $verifier->verifyAndDecode($jwt); + // 1. A token with a head that cannot be JSON decoded should throw an exception. + $caught_exception = false; + $error_msg = 'No exception caught'; + try { + $verifier->verifyAndDecode( uniqid().'.'.uniqid().'.'.uniqid() ); + } catch (InvalidTokenException $e) { + $error_msg = $e->getMessage(); + $caught_exception = $this->errorHasString($e, 'Malformed token'); + } + + $this->assertTrue($caught_exception, $error_msg); + + // 2. A token with a payload that cannot be JSON decoded should throw an exception. + $caught_exception = false; + $error_msg = 'No exception caught'; + try { + $verifier->verifyAndDecode( $dummy_segment.'.'.uniqid().'.'.uniqid() ); + } catch (InvalidTokenException $e) { + $error_msg = $e->getMessage(); + $caught_exception = $this->errorHasString($e, 'Malformed token'); + } + + $this->assertTrue($caught_exception, $error_msg); + + // 3. A token without an alg property should throw an exception. + $head_obj = (object) [ 'typ' => 'JWT' ]; + $jwt_head = JWT::urlsafeB64Encode(JWT::jsonEncode($head_obj)); + + $caught_exception = false; + $error_msg = 'No exception caught'; + try { + $verifier->verifyAndDecode( $jwt_head.'.'.$dummy_segment.'.'.uniqid() ); + } catch (InvalidTokenException $e) { + $error_msg = $e->getMessage(); + $caught_exception = $this->errorHasString($e, 'Token algorithm not found'); + } + + $this->assertTrue($caught_exception, $error_msg); + + // 4. A token with an alg not in supported_algs should throw an exception. + $head_obj->alg = 'RS256'; + $jwt_head = JWT::urlsafeB64Encode(JWT::jsonEncode($head_obj)); + + $caught_exception = false; + $error_msg = 'No exception caught'; + try { + $verifier->verifyAndDecode( $jwt_head.'.'.$dummy_segment.'.'.uniqid() ); + } catch (InvalidTokenException $e) { + $error_msg = $e->getMessage(); + $caught_exception = $this->errorHasString($e, 'Token algorithm not supported'); + } + + $this->assertTrue($caught_exception, $error_msg); + } + + /** + * Test that an invalid audience is rejected. + * + * @return void + * + * @throws CoreException See Auth0\SDK\JWTVerifier::verifyAndDecode(). + */ + public function testThatTokenWithInvalidAudThrowsException() + { + $verifier = new JWTVerifier( [ + 'valid_audiences' => [ '__valid_aud__' ], + 'supported_algs' => [ 'RS256' ], + 'authorized_iss' => [ '__valid_iss__' ], + ] ); + + // 1. A token with an invalid audience should throw an exception. + $head_obj = new \stdClass(); + $head_obj->typ = 'JWT'; + $head_obj->alg = 'RS256'; + $jwt_head = JWT::urlsafeB64Encode(JWT::jsonEncode($head_obj)); + + $payload_obj = new \stdClass(); + $payload_obj->aud = '__invalid_aud__'; + $jwt_payload = JWT::urlsafeB64Encode(JWT::jsonEncode($payload_obj)); + + $caught_exception = false; + $error_msg = 'No exception caught'; + try { + $verifier->verifyAndDecode( $jwt_head.'.'.$jwt_payload.'.'.uniqid() ); + } catch (InvalidTokenException $e) { + $error_msg = $e->getMessage(); + $caught_exception = $this->errorHasString($e, 'This token is not intended for us'); + } + + $this->assertTrue($caught_exception, $error_msg); + + // 2. A token without a key ID should throw an exception. + $payload_obj->aud = '__valid_aud__'; + $jwt_payload = JWT::urlsafeB64Encode(JWT::jsonEncode($payload_obj)); + + $caught_exception = false; + $error_msg = 'No exception caught'; + try { + $verifier->verifyAndDecode( $jwt_head.'.'.$jwt_payload.'.'.uniqid() ); + } catch (CoreException $e) { + $error_msg = $e->getMessage(); + $caught_exception = $this->errorHasString($e, 'Token key ID is missing for RS256 token'); + } + + $this->assertTrue($caught_exception, $error_msg); + + // 3. A token with an invalid issuer should throw an exception. + $head_obj->kid = uniqid(); + $jwt_head = JWT::urlsafeB64Encode(JWT::jsonEncode($head_obj)); + + $payload_obj->iss = '__invalid_iss__'; + $jwt_payload = JWT::urlsafeB64Encode(JWT::jsonEncode($payload_obj)); + + $caught_exception = false; + $error_msg = 'No exception caught'; + try { + $verifier->verifyAndDecode( $jwt_head.'.'.$jwt_payload.'.'.uniqid() ); + } catch (CoreException $e) { + $error_msg = $e->getMessage(); + $caught_exception = $this->errorHasString($e, 'We cannot trust on a token issued by'); + } + + $this->assertTrue($caught_exception, $error_msg); + + // 4. A token with an invalid signature should throw an exception. + $verifier = new JWTVerifier( [ + 'valid_audiences' => [ '__valid_aud__' ], + 'client_secret' => self::CLIENT_SECRET, + ] ); + + $head_obj->alg = 'HS256'; + $jwt_head = JWT::urlsafeB64Encode(JWT::jsonEncode($head_obj)); + + $payload_obj->iss = '__valid_iss__'; + $jwt_payload = JWT::urlsafeB64Encode(JWT::jsonEncode($payload_obj)); + + $caught_exception = false; + $error_msg = 'No exception caught'; + try { + $verifier->verifyAndDecode( $jwt_head.'.'.$jwt_payload.'.'.uniqid() ); + } catch (CoreException $e) { + $error_msg = $e->getMessage(); + $caught_exception = $this->errorHasString($e, 'Signature verification failed'); + } + + $this->assertTrue($caught_exception, $error_msg); + } + + /** + * Test a successful HS256 token decoding. + * + * @return void + * + * @throws CoreException See Auth0\SDK\JWTVerifier::verifyAndDecode(). + * @throws InvalidTokenException See Auth0\SDK\JWTVerifier::verifyAndDecode(). + */ + public function testSuccessfulHs256TokenDecoding() + { + $token_generator = new TokenGenerator( self::CLIENT_ID, self::CLIENT_SECRET ); + + // 1. Test that an encoded client secret can be used. + $verifier = new JWTVerifier( [ + 'valid_audiences' => [ self::CLIENT_ID ], + 'client_secret' => self::CLIENT_SECRET, + ] ); + $jwt = $token_generator->generate( ['users' => ['actions' => ['read']]] ); + $decoded = $verifier->verifyAndDecode($jwt); $this->assertObjectHasAttribute('aud', $decoded); - $this->assertEquals($client_id, $decoded->aud); + $this->assertEquals(self::CLIENT_ID, $decoded->aud); $this->assertObjectHasAttribute('scopes', $decoded); $this->assertObjectHasAttribute('users', $decoded->scopes); $this->assertObjectHasAttribute('actions', $decoded->scopes->users); $this->assertArraySubset(['read'], $decoded->scopes->users->actions); - } - - public function testTokenWithNotEncodedSecret() - { - $client_id = 'client_id_1'; - $client_secret = 'client_secret_1'; - - $generator = new TokenGenerator( - [ - 'client_id' => $client_id, - 'client_secret' => $client_secret, - 'secret_base64_encoded' => false - ] - ); - - $jwt = $generator->generate( - [ - 'users' => [ - 'actions' => ['read'] - ] - ] - ); - $verifier = new JWTVerifier( - [ - 'valid_audiences' => [$client_id], - 'client_secret' => $client_secret, - 'secret_base64_encoded' => false - ] + // 2. Test that a non-encoded client secret can be used. + $verifier = new JWTVerifier( [ + 'valid_audiences' => [ self::CLIENT_ID ], + 'client_secret' => self::CLIENT_SECRET, + 'secret_base64_encoded' => false, + ] ); + $jwt = $token_generator->generate( + ['users' => ['actions' => ['read']]], + TokenGenerator::DEFAULT_LIFETIME, + false ); - - $decoded = $verifier->verifyAndDecode($jwt); + $decoded = $verifier->verifyAndDecode($jwt); $this->assertObjectHasAttribute('aud', $decoded); - $this->assertEquals($client_id, $decoded->aud); + $this->assertEquals(self::CLIENT_ID, $decoded->aud); $this->assertObjectHasAttribute('scopes', $decoded); $this->assertObjectHasAttribute('users', $decoded->scopes); $this->assertObjectHasAttribute('actions', $decoded->scopes->users); $this->assertArraySubset(['read'], $decoded->scopes->users->actions); } - public function testDeprecatedTestTokenGenerationDecode() + /** + * Test a successful RS256 token decoding. + * + * @return void + * + * @throws \Exception See Auth0\SDK\JWTVerifier::verifyAndDecode(). + */ + public function testSuccessfulRs256TokenDecoding() { - $client_id = 'client_id_1'; - $client_secret = 'client_secret_1'; + // Mock the JWKFetcher object. + $mocked_jwks = $this + ->getMockBuilder( JWKFetcher::class ) + ->setMethods( [ 'requestJwkX5c' ] ) + ->getMock(); + $mocked_jwks->method( 'requestJwkX5c' )->willReturn( uniqid() ); - $generator = new TokenGenerator([ 'client_id' => $client_id, 'client_secret' => $client_secret]); + // Mock the JWT object. + $expected_sub = uniqid(); + $verifier_args = [ + 'valid_audiences' => [ self::CLIENT_ID ], + 'client_secret' => self::CLIENT_SECRET, + 'supported_algs' => [ 'RS256' ], + 'authorized_iss' => [ '__valid_iss__' ], + 'jwks_path' => 'path/to/custom/jwks.json', + ]; + $mocked_jwt = $this + ->getMockBuilder( JWTVerifier::class ) + ->setConstructorArgs( [ $verifier_args, $mocked_jwks ] ) + ->setMethods( [ 'decodeToken' ] ) + ->getMock(); + $mocked_jwt->method( 'decodeToken' )->willReturn( (object) ['sub' => $expected_sub] ); - $jwt = $generator->generate( - [ - 'users' => [ - 'actions' => ['read'] - ] - ] - ); + $head_obj = new \stdClass(); + $head_obj->typ = 'JWT'; + $head_obj->alg = 'RS256'; + $head_obj->kid = uniqid(); + $jwt_head = JWT::urlsafeB64Encode(JWT::jsonEncode($head_obj)); + + $payload_obj = new \stdClass(); + $payload_obj->aud = self::CLIENT_ID; + $payload_obj->iss = '__valid_iss__'; + $jwt_payload = JWT::urlsafeB64Encode(JWT::jsonEncode($payload_obj)); - $decoded = Auth0JWT::decode($jwt, $client_id, $client_secret); + $jwt = $jwt_head.'.'.$jwt_payload.'.'.uniqid(); + $decoded = $mocked_jwt->verifyAndDecode($jwt); + $this->assertObjectHasAttribute('sub', $decoded); + $this->assertEquals($expected_sub, $decoded->sub); + } + + /** + * Test the deprecated Auth0JWT::decode() method. + * + * @return void + */ + public function testDeprecatedTestTokenGenerationDecode() + { + $token_generator = new TokenGenerator( self::CLIENT_ID, self::CLIENT_SECRET ); + $jwt = $token_generator->generate(['users' => ['actions' => ['read']]]); + $decoded = Auth0JWT::decode($jwt, self::CLIENT_ID, self::CLIENT_SECRET); $this->assertObjectHasAttribute('aud', $decoded); - $this->assertEquals($client_id, $decoded->aud); + $this->assertEquals(self::CLIENT_ID, $decoded->aud); $this->assertObjectHasAttribute('scopes', $decoded); $this->assertObjectHasAttribute('users', $decoded->scopes); $this->assertObjectHasAttribute('actions', $decoded->scopes->users); diff --git a/tests/traits/ErrorHelpers.php b/tests/traits/ErrorHelpers.php new file mode 100644 index 00000000..b9a9b7f3 --- /dev/null +++ b/tests/traits/ErrorHelpers.php @@ -0,0 +1,19 @@ +getMessage(), $str)); + } +} From e5e51631a88b71dd78cbe4aa148abdec1600b38f Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 1 Oct 2018 21:01:30 -0700 Subject: [PATCH 3/6] PR feedback --- src/API/Helpers/TokenGenerator.php | 27 ++++++----- src/Auth0.php | 8 ++-- src/JWTVerifier.php | 76 +++++++++++++----------------- tests/API/Helpers/TokenTest.php | 4 +- 4 files changed, 53 insertions(+), 62 deletions(-) diff --git a/src/API/Helpers/TokenGenerator.php b/src/API/Helpers/TokenGenerator.php index ee3de645..fc8e4f3f 100644 --- a/src/API/Helpers/TokenGenerator.php +++ b/src/API/Helpers/TokenGenerator.php @@ -1,4 +1,5 @@ -client_id = $client_id; - $this->client_secret = $client_secret; + $this->audience = $audience; + $this->secret = $secret; } /** * Create the ID token. * * @param array $scopes Array of scopes to include. - * @param integer $lifetime Lifetime of the token. + * @param integer $lifetime Lifetime of the token, in seconds. * @param boolean $secret_encoded True to base64 decode the client secret. * * @return string @@ -57,11 +58,11 @@ public function generate(array $scopes, $lifetime = self::DEFAULT_LIFETIME, $sec 'iat' => $time, 'scopes' => $scopes, 'exp' => $time + $lifetime, - 'aud' => $this->client_id, + 'aud' => $this->audience, ]; $payload['jti'] = md5(json_encode($payload)); - $secret = $secret_encoded ? base64_decode(strtr($this->client_secret, '-_', '+/')) : $this->client_secret; + $secret = $secret_encoded ? base64_decode(strtr($this->secret, '-_', '+/')) : $this->secret; return JWT::encode($payload, $secret); } diff --git a/src/Auth0.php b/src/Auth0.php index 9f9c2dae..93492424 100644 --- a/src/Auth0.php +++ b/src/Auth0.php @@ -73,7 +73,8 @@ class Auth0 protected $clientSecret; /** - * Is the Auth0 Client Secret encoded? + * True if the client secret is base64 encoded, false if not. + * This information can be found in your Auth0 Application settings below the Client Secret field. * * @var boolean */ @@ -182,7 +183,8 @@ class Auth0 protected $guzzleOptions; /** - * Algorithm to use for ID token validation. + * Algorithm used for ID token validation. + * Can be "HS256" or "RS256" only. * * @var string */ @@ -609,7 +611,7 @@ public function setAccessToken($accessToken) } /** - * Sets and persists the ID token. + * Sets, validates, and persists the ID token. * * @param string $idToken - ID token returned from the code exchange. * diff --git a/src/JWTVerifier.php b/src/JWTVerifier.php index 7b81aa68..17e85d4b 100644 --- a/src/JWTVerifier.php +++ b/src/JWTVerifier.php @@ -23,11 +23,11 @@ class JWTVerifier * * @var JWKFetcher|null */ - protected $JWKFetcher = null; + protected $jwks_fetcher = null; /** * Algorithms supported. - * Only pass in the expected algorithm. + * Only pass in the expected algorithm (HS256 or RS256). * * @var array */ @@ -70,9 +70,9 @@ class JWTVerifier * - valid_audiences (Array) - Required; list of audiences accepted by the service. * - client_secret (String) - Required for HS256; Auth0 Application Client Secret. * - authorized_iss (Array) - Required for RS256; list of issuers trusted by the service. - * - cache (CacheHandler) - Optional. Instance of CacheHandler to cache the JWKs. * - supported_algs (Array) - List of supported algorithms; defaults to HS256. - * - guzzle_options (Array) - Extra configuration options sent to the Guzzle HTTP client. + * - cache (CacheHandler) - Optional. Instance of CacheHandler to cache the JWKs. + * - guzzle_options (Array) - Extra Guzzle HTTP client options used when getting a JWKS. * - jwks_path (string) - Path from the issuer domain to the JWKS; used for RS256. * @param JWKFetcher|null $jwkFetcher Instance of the JWKFetcher class to inject or null to instantiate. * @@ -87,8 +87,8 @@ public function __construct(array $config, JWKFetcher $jwkFetcher = null) $guzzleOptions = []; // Allow for dependency injection of a JWKFetcher object. - $this->JWKFetcher = $jwkFetcher; - if (! $this->JWKFetcher instanceof JWKFetcher) { + $this->jwks_fetcher = $jwkFetcher; + if (! $this->jwks_fetcher instanceof JWKFetcher) { // CacheHandler implementation to be used in JWKFetcher. if (isset($config['cache']) && $config['cache'] instanceof CacheHandler) { $cache = $config['cache']; @@ -99,7 +99,7 @@ public function __construct(array $config, JWKFetcher $jwkFetcher = null) $guzzleOptions = $config['guzzle_options']; } - $this->JWKFetcher = new JWKFetcher($cache, $guzzleOptions); + $this->jwks_fetcher = new JWKFetcher($cache, $guzzleOptions); } // JWKS path to use; see variable declaration above for default. @@ -109,7 +109,7 @@ public function __construct(array $config, JWKFetcher $jwkFetcher = null) // Legacy misspelling in JWT library. if (isset($config['suported_algs'])) { - throw new CoreException('`suported_algs` was properly renamed to `supported_algs`.'); + throw new CoreException('`suported_algs` was properly renamed to `supported_algs`'); } // Make sure we have audiences to check. @@ -127,26 +127,21 @@ public function __construct(array $config, JWKFetcher $jwkFetcher = null) // Check for algorithms that are not HS256 or RS256. $unsupported_algs = array_diff( $this->supported_algs, [ 'HS256', 'RS256' ] ); if (! empty( $unsupported_algs )) { - throw new CoreException( - 'Cannot support the following algorithm(s): '.implode( ', ', $unsupported_algs ).'.' - ); + throw new CoreException('Cannot support the following algorithm(s): '.implode( ', ', $unsupported_algs )); } - // Set the authorized issuer is passed. - if (isset( $config['authorized_iss'] )) { + // Set if the authorized issuer is passed; enforce if RS256. + if (! empty( $config['authorized_iss'] )) { $this->authorized_iss = $config['authorized_iss']; + } else if ($this->supportsAlg( 'RS256' )) { + throw new CoreException('The token iss property is required when accepting RS256 signed tokens'); } - // Make sure we have an issuer if the token is RS256. - if (empty($this->authorized_iss) && $this->supportsAlg( 'RS256' )) { - throw new CoreException('The iss is required when accepting RS256 signed tokens.'); - } - - // Only store the client_secret is this is an HS256 token. + // Only store the client_secret if this is an HS256 token. if ($this->supportsAlg( 'HS256' )) { // HS256 tokens require a client_secret. if (empty($config['client_secret'])) { - throw new CoreException('The client_secret is required when accepting HS256 signed tokens.'); + throw new CoreException('The client_secret is required when accepting HS256 signed tokens'); } if (! isset($config['secret_base64_encoded']) || $config['secret_base64_encoded']) { @@ -167,7 +162,7 @@ public function __construct(array $config, JWKFetcher $jwkFetcher = null) * @return mixed * * @throws InvalidTokenException If the token does not have 3 sections. - * @throws InvalidTokenException If the token does not have an algorithm or that algorithm is not supported. + * @throws InvalidTokenException If the algorithm used to sign the token is not supported. * @throws InvalidTokenException If the token does not have a valid audience. * @throws CoreException If an RS256 token is missing a key ID. * @throws CoreException If an RS256 token does not have a valid issuer. @@ -188,48 +183,41 @@ public function verifyAndDecode($jwt) throw new InvalidTokenException('Malformed token.'); } - if (! is_object($head_decoded)) { - throw new InvalidTokenException('Malformed token head.'); + if (! is_object($head_decoded) || ! is_object($body_decoded)) { + throw new InvalidTokenException('Malformed token.'); } if (empty($head_decoded->alg)) { - throw new InvalidTokenException('Token algorithm not found.'); + throw new InvalidTokenException('Token algorithm not found'); } if (! $this->supportsAlg($head_decoded->alg)) { - throw new InvalidTokenException('Token algorithm not supported.'); + throw new InvalidTokenException('Token algorithm not supported'); } - if (! is_object($body_decoded)) { - throw new InvalidTokenException('Malformed token payload.'); - } - - if (empty($body_decoded->aud)) { - throw new InvalidTokenException('Token audience not found.'); - } - - // Validate the token audience. - $audience = $body_decoded->aud; - if (! is_array($audience)) { - $audience = [$audience]; - }; - if (! count(array_intersect($audience, $this->valid_audiences))) { - throw new InvalidTokenException('This token is not intended for us.'); + // Validate the token audience, if present. + if (! empty($body_decoded->aud)) { + $audience = is_array($body_decoded->aud) ? $body_decoded->aud : [$body_decoded->aud]; + if (! count(array_intersect($audience, $this->valid_audiences))) { + throw new InvalidTokenException( + 'Invalid audience '.$body_decoded->aud.'; expected '.implode( ', ', $this->valid_audiences ) + ); + } } if ('HS256' === $head_decoded->alg) { $secret = $this->client_secret; } else { if (empty($head_decoded->kid)) { - throw new CoreException('Token key ID is missing for RS256 token.'); + throw new CoreException('Token key ID is missing for RS256 token'); } if (empty($body_decoded->iss) || ! in_array($body_decoded->iss, $this->authorized_iss)) { - throw new CoreException('We cannot trust on a token issued by `'.$body_decoded->iss.'`.'); + throw new CoreException('We cannot trust on a token issued by `'.$body_decoded->iss.'`'); } $jwks_url = $body_decoded->iss.$this->jwks_path; - $secret[$head_decoded->kid] = $this->JWKFetcher->requestJwkX5c($jwks_url, $head_decoded->kid); + $secret[$head_decoded->kid] = $this->jwks_fetcher->requestJwkX5c($jwks_url, $head_decoded->kid); } try { @@ -279,7 +267,7 @@ private function decodeB64($encoded) */ private function decodeTokenSegment($segment) { - return JWT::jsonDecode(JWT::urlsafeB64Decode($segment)); + return JWT::jsonDecode($this->decodeB64($segment)); } /** diff --git a/tests/API/Helpers/TokenTest.php b/tests/API/Helpers/TokenTest.php index 268eb739..85741eb7 100644 --- a/tests/API/Helpers/TokenTest.php +++ b/tests/API/Helpers/TokenTest.php @@ -98,7 +98,7 @@ public function testThatMissingIssThrowsException() 'supported_algs' => [ 'RS256' ], ] ); } catch (CoreException $e) { - $caught_exception = $this->errorHasString( $e, 'The iss is required' ); + $caught_exception = $this->errorHasString( $e, 'The token iss property is required' ); } $this->assertTrue($caught_exception); @@ -260,7 +260,7 @@ public function testThatTokenWithInvalidAudThrowsException() $verifier->verifyAndDecode( $jwt_head.'.'.$jwt_payload.'.'.uniqid() ); } catch (InvalidTokenException $e) { $error_msg = $e->getMessage(); - $caught_exception = $this->errorHasString($e, 'This token is not intended for us'); + $caught_exception = $this->errorHasString($e, 'Invalid audience __invalid_aud__; expected __valid_aud__'); } $this->assertTrue($caught_exception, $error_msg); From e155c56719691f7c964d32cfcc1260a84cfe24cc Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 8 Oct 2018 10:00:56 -0700 Subject: [PATCH 4/6] Explicitly require ErrorHelper trait --- tests/bootstrap.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index cf58548f..8561e923 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -6,3 +6,5 @@ if (! defined( 'AUTH0_PHP_TEST_JSON_DIR' )) { define( 'AUTH0_PHP_TEST_JSON_DIR', $tests_dir.'json/' ); } + +require_once $tests_dir.'traits/ErrorHelpers.php'; From 6070dda95aa4aac9c9bee41e8c73d7dd8b19ee4c Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 8 Oct 2018 13:38:41 -0700 Subject: [PATCH 5/6] PR feedback --- src/JWTVerifier.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/JWTVerifier.php b/src/JWTVerifier.php index 17e85d4b..97bb09bb 100644 --- a/src/JWTVerifier.php +++ b/src/JWTVerifier.php @@ -23,7 +23,7 @@ class JWTVerifier * * @var JWKFetcher|null */ - protected $jwks_fetcher = null; + protected $JWKFetcher = null; /** * Algorithms supported. @@ -87,8 +87,8 @@ public function __construct(array $config, JWKFetcher $jwkFetcher = null) $guzzleOptions = []; // Allow for dependency injection of a JWKFetcher object. - $this->jwks_fetcher = $jwkFetcher; - if (! $this->jwks_fetcher instanceof JWKFetcher) { + $this->JWKFetcher = $jwkFetcher; + if (! $this->JWKFetcher instanceof JWKFetcher) { // CacheHandler implementation to be used in JWKFetcher. if (isset($config['cache']) && $config['cache'] instanceof CacheHandler) { $cache = $config['cache']; @@ -99,7 +99,7 @@ public function __construct(array $config, JWKFetcher $jwkFetcher = null) $guzzleOptions = $config['guzzle_options']; } - $this->jwks_fetcher = new JWKFetcher($cache, $guzzleOptions); + $this->JWKFetcher = new JWKFetcher($cache, $guzzleOptions); } // JWKS path to use; see variable declaration above for default. @@ -217,7 +217,7 @@ public function verifyAndDecode($jwt) } $jwks_url = $body_decoded->iss.$this->jwks_path; - $secret[$head_decoded->kid] = $this->jwks_fetcher->requestJwkX5c($jwks_url, $head_decoded->kid); + $secret[$head_decoded->kid] = $this->JWKFetcher->requestJwkX5c($jwks_url, $head_decoded->kid); } try { From 3eb17625d22cc96a08dfccb393818039e91be0de Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 8 Oct 2018 13:46:12 -0700 Subject: [PATCH 6/6] Fix error message expected text --- tests/API/Helpers/TokenTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/API/Helpers/TokenTest.php b/tests/API/Helpers/TokenTest.php index 85741eb7..7aad21e1 100644 --- a/tests/API/Helpers/TokenTest.php +++ b/tests/API/Helpers/TokenTest.php @@ -313,7 +313,7 @@ public function testThatTokenWithInvalidAudThrowsException() $caught_exception = false; $error_msg = 'No exception caught'; try { - $verifier->verifyAndDecode( $jwt_head.'.'.$jwt_payload.'.'.uniqid() ); + $verifier->verifyAndDecode( $jwt_head.'.'.$jwt_payload.'.'.JWT::urlsafeB64Encode(uniqid()) ); } catch (CoreException $e) { $error_msg = $e->getMessage(); $caught_exception = $this->errorHasString($e, 'Signature verification failed');