From 89c0f5cf8e89e341ad16463a89071e9dd9a45bee Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Fri, 22 Nov 2019 17:09:57 -0800 Subject: [PATCH 1/6] Improve OIDC Compliance --- .circleci/config.yml | 7 +- WP_Auth0.php | 13 +- composer.json | 3 +- functions.php | 29 +- lib/WP_Auth0_Api_Client.php | 62 --- lib/WP_Auth0_Id_Token_Validator.php | 107 ----- lib/WP_Auth0_LoginManager.php | 30 +- lib/WP_Auth0_Options.php | 4 +- lib/WP_Auth0_Routes.php | 8 +- lib/admin/WP_Auth0_Admin_Advanced.php | 5 +- lib/api/WP_Auth0_Api_Abstract.php | 26 -- lib/api/WP_Auth0_Api_Get_Jwks.php | 61 +++ .../WP_Auth0_InitialSetup_Consent.php | 4 +- lib/php-jwt/BeforeValidException.php | 6 - lib/php-jwt/ExpiredException.php | 6 - lib/php-jwt/JWT.php | 367 ------------------ lib/php-jwt/SignatureInvalidException.php | 6 - .../WP_Auth0_AsymmetricVerifier.php | 54 +++ .../WP_Auth0_IdTokenVerifier.php | 265 +++++++++++++ lib/token-verifier/WP_Auth0_JwksFetcher.php | 76 ++++ .../WP_Auth0_SignatureVerifier.php | 87 +++++ .../WP_Auth0_SymmetricVerifier.php | 55 +++ tests/testIdTokenValidator.php | 267 ------------- tests/testLoginManagerRedirectLogin.php | 15 +- tests/testOptionMigrationWs.php | 8 +- tests/testRoutesGetUser.php | 3 +- tests/testRoutesLogin.php | 14 +- tests/testWpAjaxHooks.php | 4 +- tests/traits/tokenHelper.php | 35 ++ 29 files changed, 734 insertions(+), 893 deletions(-) delete mode 100644 lib/WP_Auth0_Id_Token_Validator.php create mode 100644 lib/api/WP_Auth0_Api_Get_Jwks.php delete mode 100644 lib/php-jwt/BeforeValidException.php delete mode 100644 lib/php-jwt/ExpiredException.php delete mode 100644 lib/php-jwt/JWT.php delete mode 100644 lib/php-jwt/SignatureInvalidException.php create mode 100644 lib/token-verifier/WP_Auth0_AsymmetricVerifier.php create mode 100644 lib/token-verifier/WP_Auth0_IdTokenVerifier.php create mode 100644 lib/token-verifier/WP_Auth0_JwksFetcher.php create mode 100644 lib/token-verifier/WP_Auth0_SignatureVerifier.php create mode 100644 lib/token-verifier/WP_Auth0_SymmetricVerifier.php delete mode 100644 tests/testIdTokenValidator.php create mode 100644 tests/traits/tokenHelper.php diff --git a/.circleci/config.yml b/.circleci/config.yml index 58b1069d..14a0b23e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -36,11 +36,8 @@ commands: run-formatting: steps: - run: - name: Running Linter and Formatting + name: Run i18n check and PHP compatibility command: | - composer phpcbf - composer phpcbf-tests - composer phpcs-tests composer phpcs-i18n composer compat run-tests: @@ -55,7 +52,7 @@ commands: jobs: php_7: docker: - - image: circleci/php:7.1 + - image: circleci/php:7.0 - image: circleci/mysql:5.6 environment: MYSQL_ALLOW_EMPTY_PASSWORD: true diff --git a/WP_Auth0.php b/WP_Auth0.php index c822a901..8debfe7a 100644 --- a/WP_Auth0.php +++ b/WP_Auth0.php @@ -27,9 +27,11 @@ define( 'WPA0_AUTH0_LOGIN_FORM_ID', 'auth0-login-form' ); define( 'WPA0_CACHE_GROUP', 'wp_auth0' ); define( 'WPA0_JWKS_CACHE_TRANSIENT_NAME', 'WP_Auth0_JWKS_cache' ); +define( 'WPA0_ID_TOKEN_LEEWAY', 60 ); define( 'WPA0_LANG', 'wp-auth0' ); // deprecated; do not use for translations +require_once 'vendor/autoload.php'; require_once 'functions.php'; /* @@ -434,16 +436,6 @@ public static function uninstall() { private function autoloader( $class ) { $source_dir = WPA0_PLUGIN_DIR . 'lib/'; - // Catch non-name-spaced classes that still need auto-loading. - switch ( $class ) { - case 'JWT': - case 'BeforeValidException': - case 'ExpiredException': - case 'SignatureInvalidException': - require_once $source_dir . 'php-jwt/' . $class . '.php'; - return true; - } - // Anything that's not part of the above and not name-spaced can be skipped. if ( 0 !== strpos( $class, 'WP_Auth0' ) ) { return false; @@ -457,6 +449,7 @@ private function autoloader( $class ) { $source_dir . 'profile/', $source_dir . 'wizard/', $source_dir . 'initial-setup/', + $source_dir . 'token-verifier/', ]; foreach ( $paths as $path ) { diff --git a/composer.json b/composer.json index 66472624..46052fd9 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,8 @@ "homepage": "https://auth0.com/wordpress", "license": "GPLv2", "require": { - "php": "^7.0" + "php": "^7.0", + "lcobucci/jwt": "^3.3.0" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^0.5", diff --git a/functions.php b/functions.php index c5145aca..dcaff26c 100644 --- a/functions.php +++ b/functions.php @@ -16,7 +16,7 @@ */ function wp_auth0_generate_token() { $token = WP_Auth0_Nonce_Handler::get_instance()->generate_unique( 64 ); - return JWT::urlsafeB64Encode( $token ); + return wp_auth0_url_base64_encode( $token ); } /** @@ -117,6 +117,33 @@ function wp_auth0_can_show_wp_login_form() { return false; } +/** + * @param $input + * + * @return mixed + * + * @see https://github.com/firebase/php-jwt/blob/v5.0.0/src/JWT.php#L337 + */ +function wp_auth0_url_base64_encode( $input ) { + return str_replace( '=', '', strtr( base64_encode( $input ), '+/', '-_' ) ); +} + +/** + * @param $input + * + * @return bool|string + * + * @see https://github.com/firebase/php-jwt/blob/v5.0.0/src/JWT.php#L320 + */ +function wp_auth0_url_base64_decode( $input ) { + $remainder = strlen( $input ) % 4; + if ( $remainder ) { + $padlen = 4 - $remainder; + $input .= str_repeat( '=', $padlen ); + } + return base64_decode( strtr( $input, '-_', '+/' ) ); +} + if ( ! function_exists( 'get_auth0userinfo' ) ) { /** * Get the Auth0 profile from the database, if one exists. diff --git a/lib/WP_Auth0_Api_Client.php b/lib/WP_Auth0_Api_Client.php index b16c58c6..98f1bcdf 100755 --- a/lib/WP_Auth0_Api_Client.php +++ b/lib/WP_Auth0_Api_Client.php @@ -446,68 +446,6 @@ public static function ConsentRequiredScopes() { ]; } - /** - * Convert a certificate to PEM format. - * - * @param string $cert - Certificate, like from .well-known/jwks.json. - * - * @return string - */ - protected static function convertCertToPem( $cert ) { - return '-----BEGIN CERTIFICATE-----' . PHP_EOL - . chunk_split( $cert, 64, PHP_EOL ) - . '-----END CERTIFICATE-----' . PHP_EOL; - } - - /** - * Get and cache a JWKS. - * - * @param string $domain - Issuer domain. - * - * @return array|bool|mixed - */ - public static function JWKfetch( $domain ) { - - $a0_options = WP_Auth0_Options::Instance(); - - $endpoint = "https://$domain/.well-known/jwks.json"; - - if ( false === ( $secret = get_transient( WPA0_JWKS_CACHE_TRANSIENT_NAME ) ) ) { - - $secret = []; - - $response = wp_remote_get( $endpoint, [] ); - - if ( $response instanceof WP_Error ) { - WP_Auth0_ErrorManager::insert_auth0_error( __METHOD__, $response ); - error_log( $response->get_error_message() ); - return false; - } - - if ( $response['response']['code'] != 200 ) { - WP_Auth0_ErrorManager::insert_auth0_error( __METHOD__, $response['body'] ); - error_log( $response['body'] ); - return false; - } - - if ( $response['response']['code'] >= 300 ) { - return false; - } - - $jwks = json_decode( $response['body'], true ); - - foreach ( $jwks['keys'] as $key ) { - $secret[ $key['kid'] ] = self::convertCertToPem( $key['x5c'][0] ); - } - - if ( $cache_expiration = $a0_options->get( 'cache_expiration' ) ) { - set_transient( WPA0_JWKS_CACHE_TRANSIENT_NAME, $secret, $cache_expiration * MINUTE_IN_SECONDS ); - } - } - - return $secret; - } - /** * Return the grant types needed for new clients. * diff --git a/lib/WP_Auth0_Id_Token_Validator.php b/lib/WP_Auth0_Id_Token_Validator.php deleted file mode 100644 index 0ca933cd..00000000 --- a/lib/WP_Auth0_Id_Token_Validator.php +++ /dev/null @@ -1,107 +0,0 @@ -id_token = $id_token; - - $this->key = $opts->get_client_secret_as_key(); - $this->algorithm = $opts->get_client_signing_algorithm(); - $this->issuer = 'https://' . $opts->get_auth_domain() . '/'; - $this->audience = $opts->get( 'client_id' ); - - JWT::$leeway = absint( apply_filters( 'auth0_jwt_leeway', \JWT::$leeway ) ); - } - - /** - * Decodes a JWT string into a PHP object. - * - * @param bool $validate_nonce Validate the ID token nonce. - * - * @return object - * - * @throws WP_Auth0_InvalidIdTokenException Provided JWT was invalid. - */ - public function decode( $validate_nonce = false ) { - - try { - $payload = JWT::decode( $this->id_token, $this->key, [ $this->algorithm ] ); - } catch ( Exception $e ) { - throw new WP_Auth0_InvalidIdTokenException( $e->getMessage() ); - } - - // Check if the token issuer is valid. - if ( ! isset( $payload->iss ) || $payload->iss !== $this->issuer ) { - throw new WP_Auth0_InvalidIdTokenException( __( 'Invalid token issuer', 'wp-auth0' ) ); - } - - // Check if the token audience is valid. - $token_audience = null; - if ( isset( $payload->aud ) ) { - $token_audience = is_array( $payload->aud ) ? $payload->aud : [ $payload->aud ]; - } - if ( ! $token_audience || ! in_array( $this->audience, $token_audience ) ) { - throw new WP_Auth0_InvalidIdTokenException( __( 'Invalid token audience', 'wp-auth0' ) ); - } - - // Check if the token nonce is valid. - $token_nonce = isset( $payload->nonce ) ? $payload->nonce : null; - if ( $validate_nonce && ! WP_Auth0_Nonce_Handler::get_instance()->validate( $token_nonce ) ) { - throw new WP_Auth0_InvalidIdTokenException( __( 'Invalid token nonce', 'wp-auth0' ) ); - } - - return $payload; - } -} diff --git a/lib/WP_Auth0_LoginManager.php b/lib/WP_Auth0_LoginManager.php index fbe09230..64087095 100755 --- a/lib/WP_Auth0_LoginManager.php +++ b/lib/WP_Auth0_LoginManager.php @@ -174,8 +174,7 @@ public function redirect_login() { $refresh_token = isset( $data->refresh_token ) ? $data->refresh_token : null; // Decode the incoming ID token for the Auth0 user. - $jwt_verifier = new WP_Auth0_Id_Token_Validator( $id_token, $this->a0_options ); - $decoded_token = $jwt_verifier->decode(); + $decoded_token = (object) $this->decode_id_token( $id_token ); // Attempt to authenticate with the Management API, if allowed. $userinfo = null; @@ -232,8 +231,7 @@ public function implicit_login() { $id_token_param = ! empty( $_POST['id_token'] ) ? $_POST['id_token'] : $_POST['token']; $id_token = sanitize_text_field( wp_unslash( $id_token_param ) ); - $jwt_verifier = new WP_Auth0_Id_Token_Validator( $id_token, $this->a0_options ); - $decoded_token = $jwt_verifier->decode( true ); + $decoded_token = $this->decode_id_token( $id_token ); $decoded_token = $this->clean_id_token( $decoded_token ); if ( $this->login_user( $decoded_token, $id_token ) ) { @@ -573,6 +571,30 @@ protected function die_on_login( $msg = '', $code = 0 ) { wp_die( $html ); } + /** + * @param $id_token + * @return array + * @throws WP_Auth0_InvalidIdTokenException + */ + private function decode_id_token( $id_token ) { + $idTokenIss = 'https://' . $this->a0_options->get( 'domain' ) . '/'; + $sigVerifier = null; + if ( 'RS256' === $this->a0_options->get( 'client_signing_algorithm' ) ) { + $jwks = ( new WP_Auth0_JwksFetcher() )->getKeys(); + $sigVerifier = new WP_Auth0_AsymmetricVerifier( $jwks ); + } elseif ( 'HS256' === $this->a0_options->get( 'client_signing_algorithm' ) ) { + $sigVerifier = new WP_Auth0_SymmetricVerifier( $this->a0_options->get( 'client_secret' ) ); + } + + $verifierOptions = [ + 'leeway' => absint( apply_filters( 'auth0_jwt_leeway', WPA0_ID_TOKEN_LEEWAY ) ), + 'max_age' => apply_filters( 'auth0_jwt_max_age', null ), + ]; + + $idTokenVerifier = new WP_Auth0_IdTokenVerifier( $idTokenIss, $this->a0_options->get( 'client_id' ), $sigVerifier ); + return $idTokenVerifier->verify( $id_token, $verifierOptions ); + } + /** * Remove unnecessary ID token properties. * diff --git a/lib/WP_Auth0_Options.php b/lib/WP_Auth0_Options.php index cbf374ec..62d38a41 100755 --- a/lib/WP_Auth0_Options.php +++ b/lib/WP_Auth0_Options.php @@ -58,9 +58,9 @@ public function get_client_secret_as_key( $legacy = false ) { */ public function convert_client_secret_to_key( $secret, $is_encoded, $is_RS256, $domain ) { if ( $is_RS256 ) { - return WP_Auth0_Api_Client::JWKfetch( $domain ); + return ( new WP_Auth0_JwksFetcher() )->getKeys(); } else { - return $is_encoded ? JWT::urlsafeB64Decode( $secret ) : $secret; + return $is_encoded ? wp_auth0_url_base64_decode( $secret ) : $secret; } } diff --git a/lib/WP_Auth0_Routes.php b/lib/WP_Auth0_Routes.php index 3e347c6a..8192ac8a 100755 --- a/lib/WP_Auth0_Routes.php +++ b/lib/WP_Auth0_Routes.php @@ -338,14 +338,16 @@ private function valid_token( $authorization ) { if ( $token === $authorization ) { return true; } + $client_secret = $this->a0_options->get( 'client_secret' ); if ( $this->a0_options->get( 'client_secret_base64_encoded' ) ) { - $client_secret = JWT::urlsafeB64Decode( $client_secret ); + $client_secret = wp_auth0_url_base64_decode( $client_secret ); } try { - $decoded = JWT::decode( $token, $client_secret, [ 'HS256' ] ); - return isset( $decoded->jti ) && $decoded->jti === $this->a0_options->get( 'migration_token_id' ); + $signature_verifier = new WP_Auth0_SymmetricVerifier( $client_secret ); + $decoded = $signature_verifier->verifyAndDecode( $authorization ); + return $decoded->getClaim( 'jti' ) === $this->a0_options->get( 'migration_token_id' ); } catch ( Exception $e ) { return false; } diff --git a/lib/admin/WP_Auth0_Admin_Advanced.php b/lib/admin/WP_Auth0_Admin_Advanced.php index 8da70464..33de5615 100644 --- a/lib/admin/WP_Auth0_Admin_Advanced.php +++ b/lib/admin/WP_Auth0_Admin_Advanced.php @@ -461,8 +461,9 @@ public function migration_ws_validation( array $old_options, array $input ) { } try { - $token_decoded = JWT::decode( $input['migration_token'], $secret, [ 'HS256' ] ); - $input['migration_token_id'] = isset( $token_decoded->jti ) ? $token_decoded->jti : null; + $signature_verifier = new WP_Auth0_SymmetricVerifier( $secret ); + $token_decoded = $signature_verifier->verifyAndDecode( $input['migration_token'] ); + $input['migration_token_id'] = $token_decoded->getClaim('jti'); // phpcs:ignore } catch ( Exception $e ) { diff --git a/lib/api/WP_Auth0_Api_Abstract.php b/lib/api/WP_Auth0_Api_Abstract.php index 02535c39..95de64e1 100644 --- a/lib/api/WP_Auth0_Api_Abstract.php +++ b/lib/api/WP_Auth0_Api_Abstract.php @@ -382,32 +382,6 @@ protected function handle_failed_response( $method, $success_code = 200 ) { return true; } - /** - * Decode an RS256 Auth0 Management API token. - * - * @deprecated - 3.10.0, not used. - * - * @param string $token - API JWT to decode. - * - * @return object - * - * @throws DomainException Algorithm was not provided. - * @throws UnexpectedValueException Provided JWT was invalid. - * @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed. - * @throws BeforeValidException Provided JWT used before it's eligible as defined by 'nbf'. - * @throws BeforeValidException Provided JWT used before it's been created as defined by 'iat'. - * @throws ExpiredException Provided JWT has since expired, as defined by the 'exp' claim. - * - * @codeCoverageIgnore - Deprecated. - */ - protected function decode_jwt( $token ) { - return JWT::decode( - $token, - WP_Auth0_Api_Client::JWKfetch( $this->domain ), - [ 'RS256' ] - ); - } - /** * Send the HTTP request. * diff --git a/lib/api/WP_Auth0_Api_Get_Jwks.php b/lib/api/WP_Auth0_Api_Get_Jwks.php new file mode 100644 index 00000000..9badcfe9 --- /dev/null +++ b/lib/api/WP_Auth0_Api_Get_Jwks.php @@ -0,0 +1,61 @@ +set_path( '.well-known/jwks.json' ) + ->get() + ->handle_response( __METHOD__ ); + } + + /** + * Handle API response. + * + * @param string $method - Method that called the API. + * + * @return array + */ + protected function handle_response( $method ) { + + if ( $this->handle_wp_error( $method ) ) { + return self::RETURN_ON_FAILURE; + } + + if ( $this->handle_failed_response( $method ) ) { + return self::RETURN_ON_FAILURE; + } + + return json_decode( $this->response_body, true ); + } +} diff --git a/lib/initial-setup/WP_Auth0_InitialSetup_Consent.php b/lib/initial-setup/WP_Auth0_InitialSetup_Consent.php index 4a2c13b4..f73145f9 100644 --- a/lib/initial-setup/WP_Auth0_InitialSetup_Consent.php +++ b/lib/initial-setup/WP_Auth0_InitialSetup_Consent.php @@ -66,7 +66,7 @@ public function callback() { protected function parse_token_domain( $token ) { $parts = explode( '.', $token ); - $payload = json_decode( JWT::urlsafeB64Decode( $parts[1] ) ); + $payload = json_decode( wp_auth0_url_base64_decode( $parts[1] ) ); return trim( str_replace( [ '/api/v2', 'https://' ], '', $payload->aud ), ' /' ); } @@ -161,7 +161,7 @@ public function consent_callback( $name ) { if ( $connection_exists === false ) { $migration_token = $this->a0_options->get( 'migration_token' ); if ( empty( $migration_token ) ) { - $migration_token = JWT::urlsafeB64Encode( openssl_random_pseudo_bytes( 64 ) ); + $migration_token = wp_auth0_url_base64_encode( openssl_random_pseudo_bytes( 64 ) ); } $operations = new WP_Auth0_Api_Operations( $this->a0_options ); $response = $operations->create_wordpress_connection( diff --git a/lib/php-jwt/BeforeValidException.php b/lib/php-jwt/BeforeValidException.php deleted file mode 100644 index 73f93acf..00000000 --- a/lib/php-jwt/BeforeValidException.php +++ /dev/null @@ -1,6 +0,0 @@ - - * @author Anant Narayanan - * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD - * @link https://github.com/firebase/php-jwt - */ -class JWT { - - - /** - * When checking nbf, iat or expiration times, - * we want to provide some extra leeway time to - * account for clock skew. - */ - public static $leeway = 30; - - /** - * Allow the current timestamp to be specified. - * Useful for fixing a value within unit testing. - * - * Will default to PHP time() value if null. - */ - public static $timestamp = null; - - public static $supported_algs = [ - 'HS256' => [ 'hash_hmac', 'SHA256' ], - 'HS512' => [ 'hash_hmac', 'SHA512' ], - 'HS384' => [ 'hash_hmac', 'SHA384' ], - 'RS256' => [ 'openssl', 'SHA256' ], - 'RS384' => [ 'openssl', 'SHA384' ], - 'RS512' => [ 'openssl', 'SHA512' ], - ]; - - /** - * Decodes a JWT string into a PHP object. - * - * @param string $jwt The JWT - * @param string|array $key The key, or map of keys. - * If the algorithm used is asymmetric, this is the public key - * @param array $allowed_algs List of supported verification algorithms - * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' - * - * @return object The JWT's payload as a PHP object - * - * @throws UnexpectedValueException Provided JWT was invalid - * @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed - * @throws BeforeValidException Provided JWT is trying to be used before it's eligible as defined by 'nbf' - * @throws BeforeValidException Provided JWT is trying to be used before it's been created as defined by 'iat' - * @throws ExpiredException Provided JWT has since expired, as defined by the 'exp' claim - * - * @uses jsonDecode - * @uses urlsafeB64Decode - */ - public static function decode( $jwt, $key, array $allowed_algs = [] ) { - $timestamp = is_null( static::$timestamp ) ? time() : static::$timestamp; - - if ( empty( $key ) ) { - throw new InvalidArgumentException( 'Key may not be empty' ); - } - $tks = explode( '.', $jwt ); - if ( count( $tks ) != 3 ) { - throw new UnexpectedValueException( 'Wrong number of segments' ); - } - list($headb64, $bodyb64, $cryptob64) = $tks; - if ( null === ( $header = static::jsonDecode( static::urlsafeB64Decode( $headb64 ) ) ) ) { - throw new UnexpectedValueException( 'Invalid header encoding' ); - } - if ( null === $payload = static::jsonDecode( static::urlsafeB64Decode( $bodyb64 ) ) ) { - throw new UnexpectedValueException( 'Invalid claims encoding' ); - } - if ( false === ( $sig = static::urlsafeB64Decode( $cryptob64 ) ) ) { - throw new UnexpectedValueException( 'Invalid signature encoding' ); - } - if ( empty( $header->alg ) ) { - throw new UnexpectedValueException( 'Empty algorithm' ); - } - if ( empty( static::$supported_algs[ $header->alg ] ) ) { - throw new UnexpectedValueException( 'Algorithm not supported' ); - } - if ( ! in_array( $header->alg, $allowed_algs ) ) { - throw new UnexpectedValueException( 'Algorithm not allowed' ); - } - if ( is_array( $key ) || $key instanceof \ArrayAccess ) { - if ( isset( $header->kid ) ) { - if ( ! isset( $key[ $header->kid ] ) ) { - throw new UnexpectedValueException( '"kid" invalid, unable to lookup correct key' ); - } - $key = $key[ $header->kid ]; - } else { - throw new UnexpectedValueException( '"kid" empty, unable to lookup correct key' ); - } - } - - // Check the signature - if ( ! static::verify( "$headb64.$bodyb64", $sig, $key, $header->alg ) ) { - throw new SignatureInvalidException( 'Signature verification failed' ); - } - - // Check if the nbf if it is defined. This is the time that the - // token can actually be used. If it's not yet that time, abort. - if ( isset( $payload->nbf ) && $payload->nbf > ( $timestamp + static::$leeway ) ) { - throw new BeforeValidException( - 'Cannot handle token prior to ' . date( DateTime::ISO8601, $payload->nbf ) - ); - } - - // Check that this token has been created before 'now'. This prevents - // using tokens that have been created for later use (and haven't - // correctly used the nbf claim). - if ( isset( $payload->iat ) && $payload->iat > ( $timestamp + static::$leeway ) ) { - throw new BeforeValidException( - 'Cannot handle token prior to ' . date( DateTime::ISO8601, $payload->iat ) - ); - } - - // Check if this token has expired. - if ( isset( $payload->exp ) && ( $timestamp - static::$leeway ) >= $payload->exp ) { - throw new ExpiredException( 'Expired token' ); - } - - return $payload; - } - - /** - * Converts and signs a PHP object or array into a JWT string. - * - * @param object|array $payload PHP object or array - * @param string $key The secret key. - * If the algorithm used is asymmetric, this is the private key - * @param string $alg The signing algorithm. - * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' - * @param mixed $keyId - * @param array $head An array with header elements to attach - * - * @return string A signed JWT - * - * @uses jsonEncode - * @uses urlsafeB64Encode - */ - public static function encode( $payload, $key, $alg = 'HS256', $keyId = null, $head = null ) { - $header = [ - 'typ' => 'JWT', - 'alg' => $alg, - ]; - if ( $keyId !== null ) { - $header['kid'] = $keyId; - } - if ( isset( $head ) && is_array( $head ) ) { - $header = array_merge( $head, $header ); - } - $segments = []; - $segments[] = static::urlsafeB64Encode( static::jsonEncode( $header ) ); - $segments[] = static::urlsafeB64Encode( static::jsonEncode( $payload ) ); - $signing_input = implode( '.', $segments ); - - $signature = static::sign( $signing_input, $key, $alg ); - $segments[] = static::urlsafeB64Encode( $signature ); - - return implode( '.', $segments ); - } - - /** - * Sign a string with a given key and algorithm. - * - * @param string $msg The message to sign - * @param string|resource $key The secret key - * @param string $alg The signing algorithm. - * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' - * - * @return string An encrypted message - * - * @throws DomainException Unsupported algorithm was specified - */ - public static function sign( $msg, $key, $alg = 'HS256' ) { - if ( empty( static::$supported_algs[ $alg ] ) ) { - throw new DomainException( 'Algorithm not supported' ); - } - list($function, $algorithm) = static::$supported_algs[ $alg ]; - switch ( $function ) { - case 'hash_hmac': - return hash_hmac( $algorithm, $msg, $key, true ); - case 'openssl': - $signature = ''; - $success = openssl_sign( $msg, $signature, $key, $algorithm ); - if ( ! $success ) { - throw new DomainException( 'OpenSSL unable to sign data' ); - } else { - return $signature; - } - } - } - - /** - * Verify a signature with the message, key and method. Not all methods - * are symmetric, so we must have a separate verify and sign method. - * - * @param string $msg The original message (header and body) - * @param string $signature The original signature - * @param string|resource $key For HS*, a string key works. for RS*, must be a resource of an openssl public key - * @param string $alg The algorithm - * - * @return bool - * - * @throws DomainException Invalid Algorithm or OpenSSL failure - */ - private static function verify( $msg, $signature, $key, $alg ) { - if ( empty( static::$supported_algs[ $alg ] ) ) { - throw new DomainException( 'Algorithm not supported' ); - } - - list($function, $algorithm) = static::$supported_algs[ $alg ]; - switch ( $function ) { - case 'openssl': - $success = openssl_verify( $msg, $signature, $key, $algorithm ); - if ( $success === 1 ) { - return true; - } elseif ( $success === 0 ) { - return false; - } - // returns 1 on success, 0 on failure, -1 on error. - throw new DomainException( - 'OpenSSL error: ' . openssl_error_string() - ); - case 'hash_hmac': - default: - $hash = hash_hmac( $algorithm, $msg, $key, true ); - if ( function_exists( 'hash_equals' ) ) { - // phpcs:ignore - return hash_equals( $signature, $hash ); - } - $len = min( static::safeStrlen( $signature ), static::safeStrlen( $hash ) ); - - $status = 0; - for ( $i = 0; $i < $len; $i++ ) { - $status |= ( ord( $signature[ $i ] ) ^ ord( $hash[ $i ] ) ); - } - $status |= ( static::safeStrlen( $signature ) ^ static::safeStrlen( $hash ) ); - - return ( $status === 0 ); - } - } - - /** - * Decode a JSON string into a PHP object. - * - * @param string $input JSON string - * - * @return object Object representation of JSON string - * - * @throws DomainException Provided string was invalid JSON - */ - public static function jsonDecode( $input ) { - if ( version_compare( PHP_VERSION, '5.4.0', '>=' ) && ! ( defined( 'JSON_C_VERSION' ) && PHP_INT_SIZE > 4 ) ) { - /** In PHP >=5.4.0, json_decode() accepts an options parameter, that allows you - * to specify that large ints (like Steam Transaction IDs) should be treated as - * strings, rather than the PHP default behaviour of converting them to floats. - */ - // phpcs:ignore - $obj = json_decode( $input, false, 512, JSON_BIGINT_AS_STRING ); - } else { - /** Not all servers will support that, however, so for older versions we must - * manually detect large ints in the JSON string and quote them (thus converting - *them to strings) before decoding, hence the preg_replace() call. - */ - $max_int_length = strlen( (string) PHP_INT_MAX ) - 1; - $json_without_bigints = preg_replace( '/:\s*(-?\d{' . $max_int_length . ',})/', ': "$1"', $input ); - $obj = json_decode( $json_without_bigints ); - } - - if ( function_exists( 'json_last_error' ) && $errno = json_last_error() ) { - static::handleJsonError( $errno ); - } elseif ( $obj === null && $input !== 'null' ) { - throw new DomainException( 'Null result with non-null input' ); - } - return $obj; - } - - /** - * Encode a PHP object into a JSON string. - * - * @param object|array $input A PHP object or array - * - * @return string JSON representation of the PHP object or array - * - * @throws DomainException Provided object could not be encoded to valid JSON - */ - public static function jsonEncode( $input ) { - $json = json_encode( $input ); - if ( function_exists( 'json_last_error' ) && $errno = json_last_error() ) { - static::handleJsonError( $errno ); - } elseif ( $json === 'null' && $input !== null ) { - throw new DomainException( 'Null result with non-null input' ); - } - return $json; - } - - /** - * Decode a string with URL-safe Base64. - * - * @param string $input A Base64 encoded string - * - * @return string A decoded string - */ - public static function urlsafeB64Decode( $input ) { - $remainder = strlen( $input ) % 4; - if ( $remainder ) { - $padlen = 4 - $remainder; - $input .= str_repeat( '=', $padlen ); - } - return base64_decode( strtr( $input, '-_', '+/' ) ); - } - - /** - * Encode a string with URL-safe Base64. - * - * @param string $input The string you want encoded - * - * @return string The base64 encode of what you passed in - */ - public static function urlsafeB64Encode( $input ) { - return str_replace( '=', '', strtr( base64_encode( $input ), '+/', '-_' ) ); - } - - /** - * Helper method to create a JSON error. - * - * @param int $errno An error number from json_last_error() - * - * @return void - */ - private static function handleJsonError( $errno ) { - $messages = [ - JSON_ERROR_DEPTH => 'Maximum stack depth exceeded', - JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON', - JSON_ERROR_CTRL_CHAR => 'Unexpected control character found', - JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON', - ]; - throw new DomainException( - isset( $messages[ $errno ] ) - ? $messages[ $errno ] - : 'Unknown JSON error: ' . $errno - ); - } - - /** - * Get the number of bytes in cryptographic strings. - * - * @param string - * - * @return int - */ - private static function safeStrlen( $str ) { - if ( function_exists( 'mb_strlen' ) ) { - return mb_strlen( $str, '8bit' ); - } - return strlen( $str ); - } -} diff --git a/lib/php-jwt/SignatureInvalidException.php b/lib/php-jwt/SignatureInvalidException.php deleted file mode 100644 index 769b1c85..00000000 --- a/lib/php-jwt/SignatureInvalidException.php +++ /dev/null @@ -1,6 +0,0 @@ -jwks = $jwks; + parent::__construct( 'RS256' ); + } + + /** + * Check the token kid and signature. + * + * @param Token $token Parsed token to check. + * + * @return boolean + * + * @throws WP_Auth0_InvalidIdTokenException If ID token kid was not found in the JWKS. + */ + protected function checkSignature( Token $token ) : bool { + $tokenKid = $token->getHeader( 'kid', false ); + if ( ! array_key_exists( $tokenKid, $this->jwks ) ) { + throw new WP_Auth0_InvalidIdTokenException( 'ID token key ID "' . $tokenKid . '" was not found in the JWKS' ); + } + + return $token->verify( new RsSigner(), new Key( $this->jwks[ $tokenKid ] ) ); + } +} diff --git a/lib/token-verifier/WP_Auth0_IdTokenVerifier.php b/lib/token-verifier/WP_Auth0_IdTokenVerifier.php new file mode 100644 index 00000000..8f4068e7 --- /dev/null +++ b/lib/token-verifier/WP_Auth0_IdTokenVerifier.php @@ -0,0 +1,265 @@ +issuer = $issuer; + $this->audience = $audience; + $this->verifier = $verifier; + } + + /** + * Set a new leeway time for all token checks. + * + * @param integer $newLeeway New leeway time for class instance. + * + * @return void + */ + public function setLeeway( int $newLeeway ) { + $this->leeway = $newLeeway; + } + + /** + * Verifies and decodes an OIDC-compliant ID token. + * + * @param string $token Raw JWT string. + * @param array $options Options to adjust the verification. Can be: + * - "nonce" to check the nonce contained in the token (recommended). + * - "max_age" to check the auth_time of the token. + * - "time" Unix timestamp to use as the current time for exp, iat, and auth_time checks. Used for testing. + * - "leeway" clock tolerance in seconds for the current check only. See $leeway above for default. + * + * @return array + * + * @throws WP_Auth0_InvalidIdTokenException Thrown if: + * - ID token is missing (expected but none provided) + * - Signature cannot be verified + * - Token algorithm is not supported + * - Any claim-based test fails + */ + public function verify( string $token, array $options = [] ) : array { + if ( empty( $token ) ) { + throw new WP_Auth0_InvalidIdTokenException( 'ID token is required but missing' ); + } + + $verifiedToken = $this->verifier->verifyAndDecode( $token ); + + /* + * Issuer checks + */ + + $tokenIss = $verifiedToken->getClaim( 'iss', false ); + if ( ! $tokenIss || ! is_string( $tokenIss ) ) { + throw new WP_Auth0_InvalidIdTokenException( 'Issuer (iss) claim must be a string present in the ID token' ); + } + + if ( $tokenIss !== $this->issuer ) { + throw new WP_Auth0_InvalidIdTokenException( + sprintf( + 'Issuer (iss) claim mismatch in the ID token; expected "%s", found "%s"', + $this->issuer, + $tokenIss + ) + ); + } + + /* + * Subject check + */ + + $tokenSub = $verifiedToken->getClaim( 'sub', false ); + if ( ! $tokenSub || ! is_string( $tokenSub ) ) { + throw new WP_Auth0_InvalidIdTokenException( 'Subject (sub) claim must be a string present in the ID token' ); + } + + /* + * Audience checks + */ + + $tokenAud = $verifiedToken->getClaim( 'aud', false ); + if ( ! $tokenAud || ( ! is_string( $tokenAud ) && ! is_array( $tokenAud ) ) ) { + throw new WP_Auth0_InvalidIdTokenException( + 'Audience (aud) claim must be a string or array of strings present in the ID token' + ); + } + + if ( is_array( $tokenAud ) && ! in_array( $this->audience, $tokenAud ) ) { + throw new WP_Auth0_InvalidIdTokenException( + sprintf( + 'Audience (aud) claim mismatch in the ID token; expected "%s" was not one of "%s"', + $this->audience, + implode( ', ', $tokenAud ) + ) + ); + } elseif ( is_string( $tokenAud ) && $tokenAud !== $this->audience ) { + throw new WP_Auth0_InvalidIdTokenException( + sprintf( + 'Audience (aud) claim mismatch in the ID token; expected "%s", found "%s"', + $this->audience, + $tokenAud + ) + ); + } + + /* + * Clock checks + */ + + $now = $options['time'] ?? time(); + $leeway = $options['leeway'] ?? $this->leeway; + + $tokenExp = $verifiedToken->getClaim( 'exp', false ); + if ( ! $tokenExp || ! is_int( $tokenExp ) ) { + throw new WP_Auth0_InvalidIdTokenException( 'Expiration Time (exp) claim must be a number present in the ID token' ); + } + + $expireTime = $tokenExp + $leeway; + if ( $now > $expireTime ) { + throw new WP_Auth0_InvalidIdTokenException( + sprintf( + 'Expiration Time (exp) claim error in the ID token; current time (%d) is after expiration time (%d)', + $now, + $expireTime + ) + ); + } + + $tokenIat = $verifiedToken->getClaim( 'iat', false ); + if ( ! $tokenIat || ! is_int( $tokenIat ) ) { + throw new WP_Auth0_InvalidIdTokenException( 'Issued At (iat) claim must be a number present in the ID token' ); + } + + $issuedTime = $tokenIat - $leeway; + if ( $now < $issuedTime ) { + throw new WP_Auth0_InvalidIdTokenException( + sprintf( + 'Issued At (iat) claim error in the ID token; current time (%d) is before issued at time (%d)', + $now, + $issuedTime + ) + ); + } + + /* + * Nonce check + */ + + if ( ! empty( $options['nonce'] ) ) { + $tokenNonce = $verifiedToken->getClaim( 'nonce', false ); + if ( ! $tokenNonce || ! is_string( $tokenNonce ) ) { + throw new WP_Auth0_InvalidIdTokenException( 'Nonce (nonce) claim must be a string present in the ID token' ); + } + + if ( WP_Auth0_Nonce_Handler::get_instance()->validate($tokenNonce) ) { + throw new WP_Auth0_InvalidIdTokenException( + sprintf( + 'Nonce (nonce) claim mismatch in the ID token; expected "%s", found "%s"', + $options['nonce'], + $tokenNonce + ) + ); + } + } + + /* + * Authorized party check + */ + + if ( is_array( $tokenAud ) && count( $tokenAud ) > 1 ) { + $tokenAzp = $verifiedToken->getClaim( 'azp', false ); + if ( ! $tokenAzp || ! is_string( $tokenAzp ) ) { + throw new WP_Auth0_InvalidIdTokenException( + 'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values' + ); + } + + if ( $tokenAzp !== $this->audience ) { + throw new WP_Auth0_InvalidIdTokenException( + sprintf( + 'Authorized Party (azp) claim mismatch in the ID token; expected "%s", found "%s"', + $this->audience, + $tokenAzp + ) + ); + } + } + + /* + * Authentication time check + */ + + if ( ! empty( $options['max_age'] ) ) { + $tokenAuthTime = $verifiedToken->getClaim( 'auth_time', false ); + if ( ! $tokenAuthTime || ! is_int( $tokenAuthTime ) ) { + throw new WP_Auth0_InvalidIdTokenException( + 'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified' + ); + } + + $authValidUntil = $tokenAuthTime + $options['max_age'] + $leeway; + + if ( $now > $authValidUntil ) { + throw new WP_Auth0_InvalidIdTokenException( + sprintf( + 'Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Current time (%d) is after last auth at %d', + $now, + $authValidUntil + ) + ); + } + } + + $profile = []; + foreach ( $verifiedToken->getClaims() as $claim => $value ) { + $profile[ $claim ] = $value->getValue(); + } + + return $profile; + } +} diff --git a/lib/token-verifier/WP_Auth0_JwksFetcher.php b/lib/token-verifier/WP_Auth0_JwksFetcher.php new file mode 100644 index 00000000..ac387975 --- /dev/null +++ b/lib/token-verifier/WP_Auth0_JwksFetcher.php @@ -0,0 +1,76 @@ +options = WP_Auth0_Options::Instance(); + } + + /** + * Convert a certificate to PEM format. + * + * @param string $cert X509 certificate to convert to PEM format. + * + * @return string + */ + protected function convertCertToPem( string $cert ) : string { + $output = '-----BEGIN CERTIFICATE-----' . PHP_EOL; + $output .= chunk_split( $cert, 64, PHP_EOL ); + $output .= '-----END CERTIFICATE-----' . PHP_EOL; + return $output; + } + + /** + * Gets an array of keys from the JWKS as kid => x5c. + * + * @return array + */ + public function getKeys() : array { + $keys = get_transient( WPA0_JWKS_CACHE_TRANSIENT_NAME ); + if ( is_array( $keys ) && ! empty( $keys ) ) { + return $keys; + } + + $jwks = $this->requestJwks(); + + if ( empty( $jwks ) || empty( $jwks['keys'] ) ) { + return []; + } + + $keys = []; + foreach ( $jwks['keys'] as $key ) { + if ( empty( $key['kid'] ) || empty( $key['x5c'] ) || empty( $key['x5c'][0] ) ) { + continue; + } + + $keys[ $key['kid'] ] = $this->convertCertToPem( $key['x5c'][0] ); + } + + $cache_expiration = $this->options->get( 'cache_expiration' ); + if ( $keys && $cache_expiration ) { + set_transient( WPA0_JWKS_CACHE_TRANSIENT_NAME, $keys, $cache_expiration * MINUTE_IN_SECONDS ); + } + + return $keys; + } + + /** + * Get a JWKS from a specific URL. + * + * @return array + */ + protected function requestJwks() : array { + return ( new WP_Auth0_Api_Get_Jwks( $this->options ) )->call(); + } +} diff --git a/lib/token-verifier/WP_Auth0_SignatureVerifier.php b/lib/token-verifier/WP_Auth0_SignatureVerifier.php new file mode 100644 index 00000000..f71b99e0 --- /dev/null +++ b/lib/token-verifier/WP_Auth0_SignatureVerifier.php @@ -0,0 +1,87 @@ +alg = $alg; + $this->parser = new Parser(); + } + + /** + * Format, algorithm, and signature checks. + * + * @param string $token Raw JWT ID token. + * + * @return Token + * + * @throws WP_Auth0_InvalidIdTokenException If JWT format is incorrect. + * @throws WP_Auth0_InvalidIdTokenException If token algorithm does not match the validator. + * @throws WP_Auth0_InvalidIdTokenException If token algorithm signature cannot be validated. + */ + final public function verifyAndDecode( string $token ) : Token { + try { + $parsedToken = $this->parser->parse( $token ); + } catch ( InvalidArgumentException $e ) { + throw new WP_Auth0_InvalidIdTokenException( 'ID token could not be decoded' ); + } + + $tokenAlg = $parsedToken->getHeader( 'alg', false ); + if ( $tokenAlg !== $this->alg ) { + throw new WP_Auth0_InvalidIdTokenException( + sprintf( + 'Signature algorithm of "%s" is not supported. Expected the ID token to be signed with "%s".', + $tokenAlg, + $this->alg + ) + ); + } + + if ( ! $this->checkSignature( $parsedToken ) ) { + throw new WP_Auth0_InvalidIdTokenException( 'Invalid ID token signature' ); + } + + return $parsedToken; + } +} diff --git a/lib/token-verifier/WP_Auth0_SymmetricVerifier.php b/lib/token-verifier/WP_Auth0_SymmetricVerifier.php new file mode 100644 index 00000000..f7c2dc45 --- /dev/null +++ b/lib/token-verifier/WP_Auth0_SymmetricVerifier.php @@ -0,0 +1,55 @@ +clientSecret = $clientSecret; + parent::__construct( 'HS256' ); + } + + /** + * Check the token signature. + * + * @param Token $token Parsed token to check. + * + * @return boolean + */ + protected function checkSignature( Token $token ) : bool { + return $token->verify( new HsSigner(), $this->clientSecret ); + } + + /** + * Algorithm for signature check. + * + * @return string + */ + protected function getAlgorithm() : string { + return 'HS256'; + } +} diff --git a/tests/testIdTokenValidator.php b/tests/testIdTokenValidator.php deleted file mode 100644 index 0de36146..00000000 --- a/tests/testIdTokenValidator.php +++ /dev/null @@ -1,267 +0,0 @@ -decode(); - } catch ( WP_Auth0_InvalidIdTokenException $e ) { - $caught_redirect = ( 'Key may not be empty' === $e->getMessage() ); - } - - $this->assertTrue( $caught_redirect ); - } - - /** - * Test that an invalid secret in the token fails validation. - */ - public function testThatJwtDecodeFailsWithInvalidKey() { - self::$opts->set( 'client_secret', '__test_valid_secret__' ); - self::$opts->set( 'client_signing_algorithm', 'HS256' ); - $id_token = JWT::encode( [], '__test_invalid_secret__' ); - $decoder = new WP_Auth0_Id_Token_Validator( $id_token, self::$opts ); - - try { - $caught_redirect = false; - $decoder->decode(); - } catch ( WP_Auth0_InvalidIdTokenException $e ) { - $caught_redirect = ( 'Signature verification failed' === $e->getMessage() ); - } - - $this->assertTrue( $caught_redirect ); - } - - /** - * Test that an unsupported algorithm in the token fails validation. - */ - public function testThatJwtDecodeFailsWithInvalidAlg() { - self::$opts->set( 'client_secret', '__test_valid_secret__' ); - self::$opts->set( 'client_signing_algorithm', 'HS256' ); - - $id_token = JWT::encode( [], '__test_valid_secret__', 'HS512' ); - $decoder = new WP_Auth0_Id_Token_Validator( $id_token, self::$opts ); - - try { - $caught_redirect = false; - $decoder->decode(); - } catch ( WP_Auth0_InvalidIdTokenException $e ) { - $caught_redirect = ( 'Algorithm not allowed' === $e->getMessage() ); - } - - $this->assertTrue( $caught_redirect ); - } - - /** - * Test that a missing issuer in the token fails validation. - */ - public function testThatJwtDecodeFailsWithMissingIss() { - self::$opts->set( 'client_secret', '__test_valid_secret__' ); - self::$opts->set( 'client_signing_algorithm', 'HS256' ); - - $id_token = JWT::encode( [], '__test_valid_secret__', 'HS256' ); - $decoder = new WP_Auth0_Id_Token_Validator( $id_token, self::$opts ); - - try { - $caught_redirect = false; - $decoder->decode(); - } catch ( WP_Auth0_InvalidIdTokenException $e ) { - $caught_redirect = ( 'Invalid token issuer' === $e->getMessage() ); - } - - $this->assertTrue( $caught_redirect ); - } - - /** - * Test that an issuer in the token that does not match the stored domain fails validation. - */ - public function testThatJwtDecodeFailsWithInvalidIss() { - self::$opts->set( 'client_secret', '__test_valid_secret__' ); - self::$opts->set( 'client_signing_algorithm', 'HS256' ); - self::$opts->set( 'domain', 'valid.auth0.com' ); - - $id_token = JWT::encode( [ 'iss' => 'https://invalid.auth0.com/' ], '__test_valid_secret__', 'HS256' ); - $decoder = new WP_Auth0_Id_Token_Validator( $id_token, self::$opts ); - - try { - $caught_redirect = false; - $decoder->decode(); - } catch ( WP_Auth0_InvalidIdTokenException $e ) { - $caught_redirect = ( 'Invalid token issuer' === $e->getMessage() ); - } - - $this->assertTrue( $caught_redirect ); - } - - /** - * Test that a missing audience in the token fails validation. - */ - public function testThatJwtDecodeFailsWithMissingAud() { - self::$opts->set( 'client_secret', '__test_valid_secret__' ); - self::$opts->set( 'client_signing_algorithm', 'HS256' ); - self::$opts->set( 'domain', 'valid.auth0.com' ); - - $id_token = JWT::encode( [ 'iss' => 'https://valid.auth0.com/' ], '__test_valid_secret__', 'HS256' ); - $decoder = new WP_Auth0_Id_Token_Validator( $id_token, self::$opts ); - - try { - $caught_redirect = false; - $decoder->decode(); - } catch ( WP_Auth0_InvalidIdTokenException $e ) { - $caught_redirect = ( 'Invalid token audience' === $e->getMessage() ); - } - - $this->assertTrue( $caught_redirect ); - } - - /** - * Test that a token with an audience that does not match the stored client_id fails validation. - */ - public function testThatJwtDecodeFailsWithInvalidAud() { - self::$opts->set( 'client_id', '__valid_audience__' ); - self::$opts->set( 'client_secret', '__test_valid_secret__' ); - self::$opts->set( 'client_signing_algorithm', 'HS256' ); - self::$opts->set( 'domain', 'valid.auth0.com' ); - - $id_token_payload = [ - 'iss' => 'https://valid.auth0.com/', - 'aud' => '__invalid_audience__', - ]; - $id_token = JWT::encode( $id_token_payload, '__test_valid_secret__', 'HS256' ); - $decoder = new WP_Auth0_Id_Token_Validator( $id_token, self::$opts ); - - try { - $caught_redirect = false; - $decoder->decode(); - } catch ( WP_Auth0_InvalidIdTokenException $e ) { - $caught_redirect = ( 'Invalid token audience' === $e->getMessage() ); - } - - $this->assertTrue( $caught_redirect ); - } - - /** - * Test that a token without a nonce will fail validation. - */ - public function testThatJwtDecodeFailsWithMissingNonce() { - self::$opts->set( 'client_id', '__valid_audience__' ); - self::$opts->set( 'client_secret', '__test_valid_secret__' ); - self::$opts->set( 'client_signing_algorithm', 'HS256' ); - self::$opts->set( 'domain', 'valid.auth0.com' ); - - $id_token_payload = [ - 'iss' => 'https://valid.auth0.com/', - 'aud' => '__valid_audience__', - ]; - $id_token = JWT::encode( $id_token_payload, '__test_valid_secret__', 'HS256' ); - $decoder = new WP_Auth0_Id_Token_Validator( $id_token, self::$opts ); - - try { - $caught_redirect = false; - - // Suppress "Cannot modify header information" notice. - // phpcs:ignore - @$decoder->decode( true ); - } catch ( WP_Auth0_InvalidIdTokenException $e ) { - $caught_redirect = ( 'Invalid token nonce' === $e->getMessage() ); - } - - $this->assertTrue( $caught_redirect ); - } - - /** - * Test that a token with an invalid nonce fails validation. - */ - public function testThatJwtDecodeFailsWithInvalidNonce() { - self::$opts->set( 'client_id', '__valid_audience__' ); - self::$opts->set( 'client_secret', '__test_valid_secret__' ); - self::$opts->set( 'client_signing_algorithm', 'HS256' ); - self::$opts->set( 'domain', 'valid.auth0.com' ); - $_COOKIE['auth0_nonce'] = '__valid_nonce__'; - - $id_token_payload = [ - 'iss' => 'https://valid.auth0.com/', - 'aud' => '__valid_audience__', - 'nonce' => '__invalid_nonce__', - ]; - $id_token = JWT::encode( $id_token_payload, '__test_valid_secret__', 'HS256' ); - $decoder = new WP_Auth0_Id_Token_Validator( $id_token, self::$opts ); - - try { - $caught_redirect = false; - - // Suppress "Cannot modify header information" notice. - // phpcs:ignore - @$decoder->decode( true ); - } catch ( WP_Auth0_InvalidIdTokenException $e ) { - $caught_redirect = ( 'Invalid token nonce' === $e->getMessage() ); - } - - $this->assertTrue( $caught_redirect ); - } - - /** - * Test that a token with multiple audiences succeeds. - * - * @throws WP_Auth0_InvalidIdTokenException - If token is invalid. - */ - public function testThatJwtDecodeSucceedsWithMultipleAudiences() { - self::$opts->set( 'client_id', '__valid_audience_1__' ); - self::$opts->set( 'client_secret', '__test_valid_secret__' ); - self::$opts->set( 'client_signing_algorithm', 'HS256' ); - self::$opts->set( 'domain', 'valid.auth0.com' ); - $_COOKIE['auth0_nonce'] = '__valid_nonce__'; - - $id_token_payload = [ - 'iss' => 'https://valid.auth0.com/', - 'aud' => [ '__valid_audience_1__', '__valid_audience_2__' ], - 'nonce' => '__valid_nonce__', - 'data' => '__test_data__', - ]; - $id_token = JWT::encode( $id_token_payload, '__test_valid_secret__', 'HS256' ); - $decoder = new WP_Auth0_Id_Token_Validator( $id_token, self::$opts ); - - // Suppress "Cannot modify header information" notice. - // phpcs:ignore - $decoded_token = @$decoder->decode( true ); - $this->assertEquals( '__test_data__', $decoded_token->data ); - } - - /** - * Test that the JWT leeway filter works. - */ - public function testThatJwtFilterChangesLeewayTimeUsed() { - add_filter( 'auth0_jwt_leeway', [ self::class, 'jwtLeewayFilter' ], 10 ); - new WP_Auth0_Id_Token_Validator( uniqid(), self::$opts ); - remove_filter( 'auth0_jwt_leeway', [ self::class, 'jwtLeewayFilter' ], 10 ); - - $this->assertEquals( 1234, JWT::$leeway ); - } - - /** - * Use this function to filter the JWT leeway. - * - * @return int - */ - public static function jwtLeewayFilter() { - return 1234; - } -} diff --git a/tests/testLoginManagerRedirectLogin.php b/tests/testLoginManagerRedirectLogin.php index 32f1bbb1..ec8f17c0 100644 --- a/tests/testLoginManagerRedirectLogin.php +++ b/tests/testLoginManagerRedirectLogin.php @@ -19,8 +19,11 @@ class TestLoginManagerRedirectLogin extends WP_Auth0_Test_Case { use RedirectHelpers; + use TokenHelper; + use UsersHelper; + /** * WP_Auth0_LoginManager instance to test. * @@ -98,8 +101,11 @@ public function httpMock( $response_type = null, array $args = null, $url = null 'sub' => '__test_id_token_sub__', 'iss' => 'https://test.auth0.com/', 'aud' => '__test_client_id__', + 'nonce' => '__test_nonce__', + 'exp' => time() + 1000, + 'iat' => time() - 1000, ]; - $id_token = JWT::encode( $id_token_payload, '__test_client_secret__' ); + $id_token = self::makeToken( $id_token_payload, '__test_client_secret__' ); return [ 'body' => sprintf( '{"access_token":"__test_access_token__","id_token":"%s"}', @@ -274,13 +280,13 @@ public function testThatInvalidIdTokenHaltsLogin() { $_REQUEST['code'] = uniqid(); try { - $caught_exception = false; + $e_message = 'No exception caught'; $this->login->redirect_login(); } catch ( WP_Auth0_InvalidIdTokenException $e ) { - $caught_exception = ( 'Wrong number of segments' === $e->getMessage() ); + $e_message = $e->getMessage(); } - $this->assertTrue( $caught_exception ); + $this->assertEquals( 'ID token could not be decoded', $e_message ); } /** @@ -300,6 +306,7 @@ public function testThatGetUserCallIsCorrect() { self::$opts->set( 'client_secret', '__test_client_secret__' ); self::$opts->set( 'client_signing_algorithm', 'HS256' ); $_REQUEST['code'] = uniqid(); + $_COOKIE[ 'auth0_nonce' ] = '__test_nonce__'; try { $http_data = []; diff --git a/tests/testOptionMigrationWs.php b/tests/testOptionMigrationWs.php index e3f03432..94324b04 100644 --- a/tests/testOptionMigrationWs.php +++ b/tests/testOptionMigrationWs.php @@ -16,6 +16,8 @@ class TestOptionMigrationWs extends WP_Auth0_Test_Case { use DomDocumentHelpers; + use TokenHelper; + use UsersHelper; /** @@ -156,7 +158,7 @@ public function testThatChangingMigrationToOnKeepsToken() { */ public function testThatChangingMigrationToOnKeepsWithJwtSetsId() { $client_secret = '__test_client_secret__'; - $migration_token = JWT::encode( [ 'jti' => '__test_token_id__' ], $client_secret ); + $migration_token = self::makeToken( [ 'jti' => '__test_token_id__' ], $client_secret ); self::$opts->set( 'migration_token', $migration_token ); $input = [ 'migration_ws' => 1, @@ -175,10 +177,10 @@ public function testThatChangingMigrationToOnKeepsWithJwtSetsId() { */ public function testThatChangingMigrationToOnKeepsWithBase64JwtSetsId() { $client_secret = '__test_client_secret__'; - self::$opts->set( 'migration_token', JWT::encode( [ 'jti' => '__test_token_id__' ], $client_secret ) ); + self::$opts->set( 'migration_token', self::makeToken( [ 'jti' => '__test_token_id__' ], $client_secret ) ); $input = [ 'migration_ws' => 1, - 'client_secret' => JWT::urlsafeB64Encode( $client_secret ), + 'client_secret' => wp_auth0_url_base64_encode( $client_secret ), 'client_secret_b64_encoded' => 1, ]; diff --git a/tests/testRoutesGetUser.php b/tests/testRoutesGetUser.php index eccfcb7e..2079c262 100644 --- a/tests/testRoutesGetUser.php +++ b/tests/testRoutesGetUser.php @@ -16,6 +16,7 @@ class TestRoutesGetUser extends WP_Auth0_Test_Case { use HookHelpers; + use TokenHelper; use UsersHelper; /** @@ -104,7 +105,7 @@ public function testThatGetUserRouteIsUnauthorizedIfWrongJti() { self::$opts->set( 'client_secret', $client_secret ); self::$opts->set( 'migration_token_id', '__test_token_id__' ); - $_POST['access_token'] = JWT::encode( [ 'jti' => uniqid() ], $client_secret ); + $_POST['access_token'] = self::makeToken( [ 'jti' => uniqid() ], $client_secret ); $output = json_decode( self::$routes->custom_requests( self::$wp, true ) ); diff --git a/tests/testRoutesLogin.php b/tests/testRoutesLogin.php index 0ac1c210..7d151547 100644 --- a/tests/testRoutesLogin.php +++ b/tests/testRoutesLogin.php @@ -16,6 +16,8 @@ class TestRoutesLogin extends WP_Auth0_Test_Case { use HookHelpers; + use TokenHelper; + use UsersHelper; /** @@ -109,7 +111,7 @@ public function testThatLoginRouteIsUnauthorizedIfWrongJti() { self::$opts->set( 'migration_token_id', '__test_token_id__' ); self::$wp->query_vars['a0_action'] = 'migration-ws-login'; - $_POST['access_token'] = JWT::encode( [ 'jti' => uniqid() ], $client_secret ); + $_POST['access_token'] = self::makeToken( [ 'jti' => uniqid() ], $client_secret ); $output = json_decode( self::$routes->custom_requests( self::$wp, true ) ); @@ -131,7 +133,7 @@ public function testThatLoginRouteIsUnauthorizedIfMissingJti() { self::$opts->set( 'migration_token_id', '__test_token_id__' ); self::$wp->query_vars['a0_action'] = 'migration-ws-login'; - $_POST['access_token'] = JWT::encode( [ 'iss' => uniqid() ], $client_secret ); + $_POST['access_token'] = self::makeToken( [ 'iss' => uniqid() ], $client_secret ); $output = json_decode( self::$routes->custom_requests( self::$wp, true ) ); @@ -149,7 +151,7 @@ public function testThatLoginRouteIsUnauthorizedIfMissingJti() { public function testThatLoginRouteIsBadRequestIfNoUsername() { $client_secret = '__test_client_secret__'; $token_id = '__test_token_id__'; - $migration_token = JWT::encode( [ 'jti' => $token_id ], $client_secret ); + $migration_token = self::makeToken( [ 'jti' => $token_id ], $client_secret ); self::$opts->set( 'migration_ws', 1 ); self::$opts->set( 'client_secret', $client_secret ); self::$opts->set( 'migration_token', $migration_token ); @@ -173,7 +175,7 @@ public function testThatLoginRouteIsBadRequestIfNoUsername() { public function testThatLoginRouteIsBadRequestIfNoPassword() { $client_secret = '__test_client_secret__'; $token_id = '__test_token_id__'; - $migration_token = JWT::encode( [ 'jti' => $token_id ], $client_secret ); + $migration_token = self::makeToken( [ 'jti' => $token_id ], $client_secret ); self::$opts->set( 'migration_ws', 1 ); self::$opts->set( 'client_secret', $client_secret ); self::$opts->set( 'migration_token', $migration_token ); @@ -198,7 +200,7 @@ public function testThatLoginRouteIsBadRequestIfNoPassword() { public function testThatLoginRouteIsUnauthorizedIfNotAuthenticated() { $client_secret = '__test_client_secret__'; $token_id = '__test_token_id__'; - $migration_token = JWT::encode( [ 'jti' => $token_id ], $client_secret ); + $migration_token = self::makeToken( [ 'jti' => $token_id ], $client_secret ); self::$opts->set( 'migration_ws', 1 ); self::$opts->set( 'client_secret', $client_secret ); self::$opts->set( 'migration_token', $migration_token ); @@ -233,7 +235,7 @@ public function testThatLoginRouteReturnsUserIfSuccessful() { 'user_pass' => $_POST['password'], ] ); - $migration_token = JWT::encode( [ 'jti' => $token_id ], $client_secret ); + $migration_token = self::makeToken( [ 'jti' => $token_id ], $client_secret ); self::$opts->set( 'migration_ws', 1 ); self::$opts->set( 'client_secret', $client_secret ); self::$opts->set( 'migration_token', $migration_token ); diff --git a/tests/testWpAjaxHooks.php b/tests/testWpAjaxHooks.php index 1631bc09..8170b564 100644 --- a/tests/testWpAjaxHooks.php +++ b/tests/testWpAjaxHooks.php @@ -45,8 +45,8 @@ public function testThatAjaxJwksCacheDeleteFailsWithBadNonce() { public function testThatAjaxJwksCacheDeleteSucceeds() { $this->startAjaxReturn(); - set_transient( 'WP_Auth0_JWKS_cache', '__test_cached_jwks__' ); - $this->assertEquals( '__test_cached_jwks__', WP_Auth0_Api_Client::JWKfetch( uniqid() ) ); + set_transient( 'WP_Auth0_JWKS_cache', ['__test_cached_jwks__'] ); + $this->assertEquals( ['__test_cached_jwks__'], (new WP_Auth0_JwksFetcher())->getKeys() ); $_REQUEST['_ajax_nonce'] = wp_create_nonce( 'auth0_delete_cache_transient' ); ob_start(); diff --git a/tests/traits/tokenHelper.php b/tests/traits/tokenHelper.php new file mode 100644 index 00000000..ee69b31f --- /dev/null +++ b/tests/traits/tokenHelper.php @@ -0,0 +1,35 @@ + $claim) { + $builder->withClaim($prop, $claim); + } + + return (string) $builder->getToken( new HsSigner(), new Key($secret)); + } +} From 993bf05b1d85321bb04f2684eac22e9e30f5cbfa Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 25 Nov 2019 12:55:13 -0800 Subject: [PATCH 2/6] Mandatory nonce; max_age handing --- WP_Auth0.php | 1 - lib/WP_Auth0_LoginManager.php | 24 ++--- lib/WP_Auth0_Nonce_Handler.php | 16 +++- lib/admin/WP_Auth0_Admin_Advanced.php | 2 +- .../WP_Auth0_IdTokenVerifier.php | 2 +- phpcs-test-ruleset.xml | 1 + tests/testApiGetJwks.php | 93 +++++++++++++++++++ tests/testLoginManagerAuthParams.php | 27 ++++++ tests/testLoginManagerRedirectLogin.php | 43 ++++++--- tests/testWpAjaxHooks.php | 4 +- tests/traits/httpHelpers.php | 6 ++ tests/traits/tokenHelper.php | 14 +-- 12 files changed, 189 insertions(+), 44 deletions(-) create mode 100644 tests/testApiGetJwks.php diff --git a/WP_Auth0.php b/WP_Auth0.php index 8debfe7a..3f63e496 100644 --- a/WP_Auth0.php +++ b/WP_Auth0.php @@ -27,7 +27,6 @@ define( 'WPA0_AUTH0_LOGIN_FORM_ID', 'auth0-login-form' ); define( 'WPA0_CACHE_GROUP', 'wp_auth0' ); define( 'WPA0_JWKS_CACHE_TRANSIENT_NAME', 'WP_Auth0_JWKS_cache' ); -define( 'WPA0_ID_TOKEN_LEEWAY', 60 ); define( 'WPA0_LANG', 'wp-auth0' ); // deprecated; do not use for translations diff --git a/lib/WP_Auth0_LoginManager.php b/lib/WP_Auth0_LoginManager.php index 64087095..c3fbd313 100755 --- a/lib/WP_Auth0_LoginManager.php +++ b/lib/WP_Auth0_LoginManager.php @@ -78,10 +78,7 @@ public function login_auto() { $auth_params = self::get_authorize_params( $connection ); WP_Auth0_State_Handler::get_instance()->set_cookie( $auth_params['state'] ); - - if ( isset( $auth_params['nonce'] ) ) { - WP_Auth0_Nonce_Handler::get_instance()->set_cookie(); - } + WP_Auth0_Nonce_Handler::get_instance()->set_cookie( $auth_params['nonce'] ); $auth_url = self::build_authorize_url( $auth_params ); wp_redirect( $auth_url ); @@ -458,24 +455,18 @@ public static function get_authorize_params( $connection = null, $redirect_to = $opts = WP_Auth0_Options::Instance(); $lock_options = new WP_Auth0_Lock10_Options(); $is_implicit = (bool) $opts->get( 'auth0_implicit_workflow', false ); - $nonce = WP_Auth0_Nonce_Handler::get_instance()->get_unique(); $params = [ + 'connection' => $connection, 'client_id' => $opts->get( 'client_id' ), 'scope' => self::get_userinfo_scope( 'authorize_url' ), + 'nonce' => WP_Auth0_Nonce_Handler::get_instance()->get_unique(), + 'max_age' => apply_filters( 'auth0_jwt_max_age', null ), 'response_type' => $is_implicit ? 'id_token' : 'code', 'response_mode' => $is_implicit ? 'form_post' : 'query', 'redirect_uri' => $is_implicit ? $lock_options->get_implicit_callback_url() : $opts->get_wp_auth0_url(), ]; - if ( $is_implicit ) { - $params['nonce'] = $nonce; - } - - if ( ! empty( $connection ) ) { - $params['connection'] = $connection; - } - // Where should the user be redirected after logging in? if ( empty( $redirect_to ) ) { $redirect_to = empty( $_GET['redirect_to'] ) @@ -496,7 +487,7 @@ public static function get_authorize_params( $connection = null, $redirect_to = $filtered_params['state'] = base64_encode( json_encode( $filtered_state ) ); } - return $filtered_params; + return array_filter( $filtered_params ); } /** @@ -586,8 +577,9 @@ private function decode_id_token( $id_token ) { $sigVerifier = new WP_Auth0_SymmetricVerifier( $this->a0_options->get( 'client_secret' ) ); } - $verifierOptions = [ - 'leeway' => absint( apply_filters( 'auth0_jwt_leeway', WPA0_ID_TOKEN_LEEWAY ) ), + $verifierOptions = [ + 'nonce' => WP_Auth0_Nonce_Handler::get_instance()->get_once(), + 'leeway' => absint( apply_filters( 'auth0_jwt_leeway', null ) ), 'max_age' => apply_filters( 'auth0_jwt_max_age', null ), ]; diff --git a/lib/WP_Auth0_Nonce_Handler.php b/lib/WP_Auth0_Nonce_Handler.php index ac75889e..603a0b36 100644 --- a/lib/WP_Auth0_Nonce_Handler.php +++ b/lib/WP_Auth0_Nonce_Handler.php @@ -129,6 +129,18 @@ public function validate( $value ) { return $valid; } + /** + * Get and delete the stored value. + * + * @return string|null + */ + public function get_once() { + $cookie_name = static::get_storage_cookie_name(); + $value = $_COOKIE[ $cookie_name ] ?? null; + $this->reset(); + return $value; + } + /** * Reset/delete a cookie. * @@ -163,11 +175,11 @@ public function generate_unique( $bytes = 32 ) { protected function handle_cookie( $cookie_name, $cookie_value, $cookie_exp ) { if ( $cookie_exp <= time() ) { unset( $_COOKIE[ $cookie_name ] ); - $cookie_exp = 0; + return setcookie( $cookie_name, $cookie_value, 0, '/' ); } else { $_COOKIE[ $cookie_name ] = $cookie_value; + return setcookie( $cookie_name, $cookie_value, $cookie_exp, '/', '', false, true ); } - return setcookie( $cookie_name, $cookie_value, $cookie_exp, '/' ); } /** diff --git a/lib/admin/WP_Auth0_Admin_Advanced.php b/lib/admin/WP_Auth0_Admin_Advanced.php index 33de5615..140ec758 100644 --- a/lib/admin/WP_Auth0_Admin_Advanced.php +++ b/lib/admin/WP_Auth0_Admin_Advanced.php @@ -463,7 +463,7 @@ public function migration_ws_validation( array $old_options, array $input ) { try { $signature_verifier = new WP_Auth0_SymmetricVerifier( $secret ); $token_decoded = $signature_verifier->verifyAndDecode( $input['migration_token'] ); - $input['migration_token_id'] = $token_decoded->getClaim('jti'); + $input['migration_token_id'] = $token_decoded->getClaim( 'jti' ); // phpcs:ignore } catch ( Exception $e ) { diff --git a/lib/token-verifier/WP_Auth0_IdTokenVerifier.php b/lib/token-verifier/WP_Auth0_IdTokenVerifier.php index 8f4068e7..47a99790 100644 --- a/lib/token-verifier/WP_Auth0_IdTokenVerifier.php +++ b/lib/token-verifier/WP_Auth0_IdTokenVerifier.php @@ -196,7 +196,7 @@ public function verify( string $token, array $options = [] ) : array { throw new WP_Auth0_InvalidIdTokenException( 'Nonce (nonce) claim must be a string present in the ID token' ); } - if ( WP_Auth0_Nonce_Handler::get_instance()->validate($tokenNonce) ) { + if ( $tokenNonce !== $options['nonce'] ) { throw new WP_Auth0_InvalidIdTokenException( sprintf( 'Nonce (nonce) claim mismatch in the ID token; expected "%s", found "%s"', diff --git a/phpcs-test-ruleset.xml b/phpcs-test-ruleset.xml index c2ffa788..f10daed8 100644 --- a/phpcs-test-ruleset.xml +++ b/phpcs-test-ruleset.xml @@ -23,6 +23,7 @@ + diff --git a/tests/testApiGetJwks.php b/tests/testApiGetJwks.php new file mode 100644 index 00000000..1a60162e --- /dev/null +++ b/tests/testApiGetJwks.php @@ -0,0 +1,93 @@ +startHttpHalting(); + + self::$opts->set( 'domain', 'test.auth0.com' ); + $get_jwks_api = new WP_Auth0_Api_Get_Jwks( self::$opts ); + + try { + $http_data = []; + $get_jwks_api->call(); + } catch ( Exception $e ) { + $http_data = unserialize( $e->getMessage() ); + } + + $this->assertEquals( 'https://test.auth0.com/.well-known/jwks.json', $http_data['url'] ); + $this->assertEquals( + WP_Auth0_Api_Abstract::get_info_headers()['Auth0-Client'], + $http_data['headers']['Auth0-Client'] + ); + $this->assertEquals( 'GET', $http_data['method'] ); + } + + /** + * Test that a network error (caught by WP and returned as a WP_Error) is handled properly. + */ + public function testThatNetworkErrorIsHandled() { + $this->startHttpMocking(); + $this->http_request_type = 'wp_error'; + + self::$opts->set( 'domain', 'test.auth0.com' ); + $get_jwks_api = new WP_Auth0_Api_Get_Jwks( self::$opts ); + + $this->assertEquals( [], $get_jwks_api->call() ); + + $log = self::$error_log->get(); + $this->assertCount( 1, $log ); + $this->assertEquals( 'WP_Auth0_Api_Get_Jwks::call', $log[0]['section'] ); + } + + /** + * Test that a network error (caught by WP and returned as a WP_Error) is handled properly. + */ + public function testThatApiErrorIsHandled() { + $this->startHttpMocking(); + $this->http_request_type = 'auth0_api_error'; + + self::$opts->set( 'domain', 'test.auth0.com' ); + $get_jwks_api = new WP_Auth0_Api_Get_Jwks( self::$opts ); + + $this->assertEquals( [], $get_jwks_api->call() ); + + $log = self::$error_log->get(); + $this->assertCount( 1, $log ); + $this->assertEquals( 'WP_Auth0_Api_Get_Jwks::call', $log[0]['section'] ); + } + + /** + * Test that a network error (caught by WP and returned as a WP_Error) is handled properly. + */ + public function testThatSuccessfulCallIsReturned() { + $this->startHttpMocking(); + $this->http_request_type = 'success_jwks'; + + self::$opts->set( 'domain', 'test.auth0.com' ); + $get_jwks_api = new WP_Auth0_Api_Get_Jwks( self::$opts ); + $jwks_result = $get_jwks_api->call(); + + $this->assertArrayHasKey( 'keys', $jwks_result ); + $this->assertCount( 1, $jwks_result['keys'] ); + $this->assertArrayHasKey( 'x5c', $jwks_result['keys'][0] ); + $this->assertArrayHasKey( 'kid', $jwks_result['keys'][0] ); + $this->assertEquals( '__test_kid_1__', $jwks_result['keys'][0]['kid'] ); + } +} diff --git a/tests/testLoginManagerAuthParams.php b/tests/testLoginManagerAuthParams.php index 599ef54b..ec0645ae 100644 --- a/tests/testLoginManagerAuthParams.php +++ b/tests/testLoginManagerAuthParams.php @@ -21,6 +21,7 @@ public function testThatDefaultAuthParamsAreCorrect() { $this->assertEquals( 'openid email profile', $auth_params['scope'] ); $this->assertEquals( 'code', $auth_params['response_type'] ); $this->assertEquals( 'query', $auth_params['response_mode'] ); + $this->assertEquals( WP_Auth0_Nonce_Handler::get_instance()->get_unique(), $auth_params['nonce'] ); $this->assertEquals( site_url( 'index.php?auth0=1' ), $auth_params['redirect_uri'] ); $this->assertArrayNotHasKey( 'auth0Client', $auth_params ); @@ -92,6 +93,21 @@ public function testThatStateFilterIsApplied() { remove_filter( 'auth0_authorize_state', [ $this, 'filter_state_parms' ], 10 ); } + public function testThatMaxAgeFilterIsApplied() { + $auth_params = WP_Auth0_LoginManager::get_authorize_params(); + + $this->assertArrayNotHasKey( 'max_age', $auth_params ); + + add_filter( 'auth0_jwt_max_age', [ $this, 'filter_max_age' ], 10, 2 ); + + $auth_params = WP_Auth0_LoginManager::get_authorize_params(); + + $this->assertArrayHasKey( 'max_age', $auth_params ); + $this->assertEquals( 1234, $auth_params['max_age'] ); + + remove_filter( 'auth0_jwt_max_age', [ $this, 'filter_max_age' ], 10 ); + } + /** * Helper method to filter the auth params for testing. * @@ -120,4 +136,15 @@ public function filter_state_parms( $state, $filtered_params ) { $state['response_type_repeat'] = $filtered_params['response_type']; return $state; } + + /** + * Helper method to filter max_age for testing. + * + * @param null $max_age_null - Existing max_age setting. + * + * @return int + */ + public function filter_max_age( $max_age_null ) { + return 1234; + } } diff --git a/tests/testLoginManagerRedirectLogin.php b/tests/testLoginManagerRedirectLogin.php index ec8f17c0..43737451 100644 --- a/tests/testLoginManagerRedirectLogin.php +++ b/tests/testLoginManagerRedirectLogin.php @@ -98,12 +98,12 @@ public function httpMock( $response_type = null, array $args = null, $url = null switch ( $response_type ) { case 'success_exchange_code_valid_id_token': $id_token_payload = [ - 'sub' => '__test_id_token_sub__', - 'iss' => 'https://test.auth0.com/', - 'aud' => '__test_client_id__', + 'sub' => '__test_id_token_sub__', + 'iss' => 'https://test.auth0.com/', + 'aud' => '__test_client_id__', 'nonce' => '__test_nonce__', - 'exp' => time() + 1000, - 'iat' => time() - 1000, + 'exp' => time() + 1000, + 'iat' => time() - 1000, ]; $id_token = self::makeToken( $id_token_payload, '__test_client_secret__' ); return [ @@ -281,7 +281,9 @@ public function testThatInvalidIdTokenHaltsLogin() { try { $e_message = 'No exception caught'; - $this->login->redirect_login(); + // Need to hide error messages here because a cookie is set. + // phpcs:ignore + @$this->login->redirect_login(); } catch ( WP_Auth0_InvalidIdTokenException $e ) { $e_message = $e->getMessage(); } @@ -305,12 +307,14 @@ public function testThatGetUserCallIsCorrect() { self::$opts->set( 'client_id', '__test_client_id__' ); self::$opts->set( 'client_secret', '__test_client_secret__' ); self::$opts->set( 'client_signing_algorithm', 'HS256' ); - $_REQUEST['code'] = uniqid(); - $_COOKIE[ 'auth0_nonce' ] = '__test_nonce__'; + $_REQUEST['code'] = uniqid(); + $_COOKIE['auth0_nonce'] = '__test_nonce__'; try { $http_data = []; - $this->login->redirect_login(); + // Need to hide error messages here because a cookie is set. + // phpcs:ignore + @$this->login->redirect_login(); } catch ( Exception $e ) { $http_data = unserialize( $e->getMessage() ); } @@ -336,11 +340,14 @@ public function testThatLoginUserIsCalledWithManagementApiUserinfo() { self::$opts->set( 'client_id', '__test_client_id__' ); self::$opts->set( 'client_secret', '__test_client_secret__' ); self::$opts->set( 'client_signing_algorithm', 'HS256' ); - $_REQUEST['code'] = uniqid(); + $_REQUEST['code'] = uniqid(); + $_COOKIE['auth0_nonce'] = '__test_nonce__'; try { $user_data = []; - $this->login->redirect_login(); + // Need to hide error messages here because a cookie is set. + // phpcs:ignore + @$this->login->redirect_login(); } catch ( Exception $e ) { $user_data = unserialize( $e->getMessage() ); } @@ -369,11 +376,14 @@ public function testThatLoginUserIsCalledWithIdTokenIfNoApiAccess() { self::$opts->set( 'client_id', '__test_client_id__' ); self::$opts->set( 'client_secret', '__test_client_secret__' ); self::$opts->set( 'client_signing_algorithm', 'HS256' ); - $_REQUEST['code'] = uniqid(); + $_REQUEST['code'] = uniqid(); + $_COOKIE['auth0_nonce'] = '__test_nonce__'; try { $user_data = []; - $this->login->redirect_login(); + // Need to hide error messages here because a cookie is set. + // phpcs:ignore + @$this->login->redirect_login(); } catch ( Exception $e ) { $user_data = unserialize( $e->getMessage() ); } @@ -400,11 +410,14 @@ public function testThatLoginUserIsCalledWithIdTokenIfFilterIsSetToFalse() { self::$opts->set( 'client_secret', '__test_client_secret__' ); self::$opts->set( 'client_signing_algorithm', 'HS256' ); add_filter( 'auth0_use_management_api_for_userinfo', '__return_false', 10 ); - $_REQUEST['code'] = uniqid(); + $_REQUEST['code'] = uniqid(); + $_COOKIE['auth0_nonce'] = '__test_nonce__'; try { $user_data = []; - $this->login->redirect_login(); + // Need to hide error messages here because a cookie is set. + // phpcs:ignore + @$this->login->redirect_login(); } catch ( Exception $e ) { $user_data = unserialize( $e->getMessage() ); } diff --git a/tests/testWpAjaxHooks.php b/tests/testWpAjaxHooks.php index 8170b564..cace955c 100644 --- a/tests/testWpAjaxHooks.php +++ b/tests/testWpAjaxHooks.php @@ -45,8 +45,8 @@ public function testThatAjaxJwksCacheDeleteFailsWithBadNonce() { public function testThatAjaxJwksCacheDeleteSucceeds() { $this->startAjaxReturn(); - set_transient( 'WP_Auth0_JWKS_cache', ['__test_cached_jwks__'] ); - $this->assertEquals( ['__test_cached_jwks__'], (new WP_Auth0_JwksFetcher())->getKeys() ); + set_transient( 'WP_Auth0_JWKS_cache', [ '__test_cached_jwks__' ] ); + $this->assertEquals( [ '__test_cached_jwks__' ], ( new WP_Auth0_JwksFetcher() )->getKeys() ); $_REQUEST['_ajax_nonce'] = wp_create_nonce( 'auth0_delete_cache_transient' ); ob_start(); diff --git a/tests/traits/httpHelpers.php b/tests/traits/httpHelpers.php index 147ffdf9..ffd3526f 100644 --- a/tests/traits/httpHelpers.php +++ b/tests/traits/httpHelpers.php @@ -195,6 +195,12 @@ public function httpMock( $response_type = null, array $args = null, $url = null 'response' => [ 'code' => 200 ], ]; + case 'success_jwks': + return [ + 'body' => '{"keys":[{"x5c":["__test_x5c_1__"],"kid":"__test_kid_1__"}]}', + 'response' => [ 'code' => 200 ], + ]; + default: return new WP_Error( 2, 'No mock type found.' ); } diff --git a/tests/traits/tokenHelper.php b/tests/traits/tokenHelper.php index ee69b31f..a2c688f2 100644 --- a/tests/traits/tokenHelper.php +++ b/tests/traits/tokenHelper.php @@ -18,18 +18,20 @@ trait TokenHelper { /** - * @param string $secret - * @param array $claims + * Create an HS256 token for testing. + * + * @param array $claims Claims to include in the payload. + * @param string $secret Signing key to use. * * @return string */ - public function makeToken($claims = [], $secret = '__test_secret__') { + public function makeToken( $claims = [], $secret = '__test_secret__' ) { $builder = new Builder(); - foreach ($claims as $prop => $claim) { - $builder->withClaim($prop, $claim); + foreach ( $claims as $prop => $claim ) { + $builder->withClaim( $prop, $claim ); } - return (string) $builder->getToken( new HsSigner(), new Key($secret)); + return (string) $builder->getToken( new HsSigner(), new Key( $secret ) ); } } From 79eac9e34eb6eb15769237380ce2e840f6ece927 Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 25 Nov 2019 13:05:37 -0800 Subject: [PATCH 3/6] Ignore code coverage on PHP-SDK sourced classes --- lib/token-verifier/WP_Auth0_AsymmetricVerifier.php | 2 ++ lib/token-verifier/WP_Auth0_IdTokenVerifier.php | 2 ++ lib/token-verifier/WP_Auth0_JwksFetcher.php | 2 ++ lib/token-verifier/WP_Auth0_SignatureVerifier.php | 2 ++ lib/token-verifier/WP_Auth0_SymmetricVerifier.php | 2 ++ 5 files changed, 10 insertions(+) diff --git a/lib/token-verifier/WP_Auth0_AsymmetricVerifier.php b/lib/token-verifier/WP_Auth0_AsymmetricVerifier.php index 73b6bd05..7ea50a90 100644 --- a/lib/token-verifier/WP_Auth0_AsymmetricVerifier.php +++ b/lib/token-verifier/WP_Auth0_AsymmetricVerifier.php @@ -13,6 +13,8 @@ /** * Class WP_Auth0_AsymmetricVerifier + * + * @codeCoverageIgnore - Classes are adapted from the PHP SDK and tested there. */ final class WP_Auth0_AsymmetricVerifier extends WP_Auth0_SignatureVerifier { diff --git a/lib/token-verifier/WP_Auth0_IdTokenVerifier.php b/lib/token-verifier/WP_Auth0_IdTokenVerifier.php index 47a99790..e9959b87 100644 --- a/lib/token-verifier/WP_Auth0_IdTokenVerifier.php +++ b/lib/token-verifier/WP_Auth0_IdTokenVerifier.php @@ -9,6 +9,8 @@ /** * Class WP_Auth0_IdTokenVerifier + * + * @codeCoverageIgnore - Classes are adapted from the PHP SDK and tested there. */ final class WP_Auth0_IdTokenVerifier { diff --git a/lib/token-verifier/WP_Auth0_JwksFetcher.php b/lib/token-verifier/WP_Auth0_JwksFetcher.php index ac387975..de570f4d 100644 --- a/lib/token-verifier/WP_Auth0_JwksFetcher.php +++ b/lib/token-verifier/WP_Auth0_JwksFetcher.php @@ -2,6 +2,8 @@ /** * Class JWKFetcher. + * + * @codeCoverageIgnore - Classes are adapted from the PHP SDK and tested there. */ class WP_Auth0_JwksFetcher { diff --git a/lib/token-verifier/WP_Auth0_SignatureVerifier.php b/lib/token-verifier/WP_Auth0_SignatureVerifier.php index f71b99e0..426bb9f7 100644 --- a/lib/token-verifier/WP_Auth0_SignatureVerifier.php +++ b/lib/token-verifier/WP_Auth0_SignatureVerifier.php @@ -12,6 +12,8 @@ /** * Class WP_Auth0_SignatureVerifier + * + * @codeCoverageIgnore - Classes are adapted from the PHP SDK and tested there. */ abstract class WP_Auth0_SignatureVerifier { diff --git a/lib/token-verifier/WP_Auth0_SymmetricVerifier.php b/lib/token-verifier/WP_Auth0_SymmetricVerifier.php index f7c2dc45..cfc3497a 100644 --- a/lib/token-verifier/WP_Auth0_SymmetricVerifier.php +++ b/lib/token-verifier/WP_Auth0_SymmetricVerifier.php @@ -12,6 +12,8 @@ /** * Class WP_Auth0_SymmetricVerifier + * + * @codeCoverageIgnore - Classes are adapted from the PHP SDK and tested there. */ final class WP_Auth0_SymmetricVerifier extends WP_Auth0_SignatureVerifier { From 06e1d3b5c328b213f1b4a239bbf83280c9ace31f Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 25 Nov 2019 13:11:25 -0800 Subject: [PATCH 4/6] Add absint to max_age filter output --- lib/WP_Auth0_LoginManager.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/WP_Auth0_LoginManager.php b/lib/WP_Auth0_LoginManager.php index c3fbd313..46388c58 100755 --- a/lib/WP_Auth0_LoginManager.php +++ b/lib/WP_Auth0_LoginManager.php @@ -461,7 +461,7 @@ public static function get_authorize_params( $connection = null, $redirect_to = 'client_id' => $opts->get( 'client_id' ), 'scope' => self::get_userinfo_scope( 'authorize_url' ), 'nonce' => WP_Auth0_Nonce_Handler::get_instance()->get_unique(), - 'max_age' => apply_filters( 'auth0_jwt_max_age', null ), + 'max_age' => absint( apply_filters( 'auth0_jwt_max_age', null ) ), 'response_type' => $is_implicit ? 'id_token' : 'code', 'response_mode' => $is_implicit ? 'form_post' : 'query', 'redirect_uri' => $is_implicit ? $lock_options->get_implicit_callback_url() : $opts->get_wp_auth0_url(), @@ -580,7 +580,7 @@ private function decode_id_token( $id_token ) { $verifierOptions = [ 'nonce' => WP_Auth0_Nonce_Handler::get_instance()->get_once(), 'leeway' => absint( apply_filters( 'auth0_jwt_leeway', null ) ), - 'max_age' => apply_filters( 'auth0_jwt_max_age', null ), + 'max_age' => absint( apply_filters( 'auth0_jwt_max_age', null ) ), ]; $idTokenVerifier = new WP_Auth0_IdTokenVerifier( $idTokenIss, $this->a0_options->get( 'client_id' ), $sigVerifier ); From 5f767be71e7777cff2b713241260086f21cbd0e1 Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 25 Nov 2019 13:29:48 -0800 Subject: [PATCH 5/6] Decode token return type change --- lib/WP_Auth0_LoginManager.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/WP_Auth0_LoginManager.php b/lib/WP_Auth0_LoginManager.php index 46388c58..5875e6ce 100755 --- a/lib/WP_Auth0_LoginManager.php +++ b/lib/WP_Auth0_LoginManager.php @@ -171,7 +171,7 @@ public function redirect_login() { $refresh_token = isset( $data->refresh_token ) ? $data->refresh_token : null; // Decode the incoming ID token for the Auth0 user. - $decoded_token = (object) $this->decode_id_token( $id_token ); + $decoded_token = $this->decode_id_token( $id_token ); // Attempt to authenticate with the Management API, if allowed. $userinfo = null; @@ -564,7 +564,7 @@ protected function die_on_login( $msg = '', $code = 0 ) { /** * @param $id_token - * @return array + * @return object * @throws WP_Auth0_InvalidIdTokenException */ private function decode_id_token( $id_token ) { @@ -584,7 +584,7 @@ private function decode_id_token( $id_token ) { ]; $idTokenVerifier = new WP_Auth0_IdTokenVerifier( $idTokenIss, $this->a0_options->get( 'client_id' ), $sigVerifier ); - return $idTokenVerifier->verify( $id_token, $verifierOptions ); + return (object) $idTokenVerifier->verify( $id_token, $verifierOptions ); } /** From 6c8bc4b8ed4d4cb94b1e83710e9c23486c145bd3 Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 2 Dec 2019 13:23:08 -0800 Subject: [PATCH 6/6] More clear logic for expected token alg --- lib/WP_Auth0_LoginManager.php | 12 +++++++----- lib/token-verifier/WP_Auth0_AsymmetricVerifier.php | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/WP_Auth0_LoginManager.php b/lib/WP_Auth0_LoginManager.php index 5875e6ce..2cbc66fa 100755 --- a/lib/WP_Auth0_LoginManager.php +++ b/lib/WP_Auth0_LoginManager.php @@ -568,13 +568,15 @@ protected function die_on_login( $msg = '', $code = 0 ) { * @throws WP_Auth0_InvalidIdTokenException */ private function decode_id_token( $id_token ) { - $idTokenIss = 'https://' . $this->a0_options->get( 'domain' ) . '/'; - $sigVerifier = null; - if ( 'RS256' === $this->a0_options->get( 'client_signing_algorithm' ) ) { + $expectedIss = 'https://' . $this->a0_options->get( 'domain' ) . '/'; + $expectedAlg = $this->a0_options->get( 'client_signing_algorithm' ); + if ( 'RS256' === $expectedAlg ) { $jwks = ( new WP_Auth0_JwksFetcher() )->getKeys(); $sigVerifier = new WP_Auth0_AsymmetricVerifier( $jwks ); - } elseif ( 'HS256' === $this->a0_options->get( 'client_signing_algorithm' ) ) { + } elseif ( 'HS256' === $expectedAlg ) { $sigVerifier = new WP_Auth0_SymmetricVerifier( $this->a0_options->get( 'client_secret' ) ); + } else { + throw new WP_Auth0_InvalidIdTokenException( 'Signing algorithm of "' . $expectedAlg . '" is not supported.' ); } $verifierOptions = [ @@ -583,7 +585,7 @@ private function decode_id_token( $id_token ) { 'max_age' => absint( apply_filters( 'auth0_jwt_max_age', null ) ), ]; - $idTokenVerifier = new WP_Auth0_IdTokenVerifier( $idTokenIss, $this->a0_options->get( 'client_id' ), $sigVerifier ); + $idTokenVerifier = new WP_Auth0_IdTokenVerifier( $expectedIss, $this->a0_options->get( 'client_id' ), $sigVerifier ); return (object) $idTokenVerifier->verify( $id_token, $verifierOptions ); } diff --git a/lib/token-verifier/WP_Auth0_AsymmetricVerifier.php b/lib/token-verifier/WP_Auth0_AsymmetricVerifier.php index 7ea50a90..4c8af7d8 100644 --- a/lib/token-verifier/WP_Auth0_AsymmetricVerifier.php +++ b/lib/token-verifier/WP_Auth0_AsymmetricVerifier.php @@ -48,7 +48,7 @@ public function __construct( array $jwks ) { protected function checkSignature( Token $token ) : bool { $tokenKid = $token->getHeader( 'kid', false ); if ( ! array_key_exists( $tokenKid, $this->jwks ) ) { - throw new WP_Auth0_InvalidIdTokenException( 'ID token key ID "' . $tokenKid . '" was not found in the JWKS' ); + throw new WP_Auth0_InvalidIdTokenException( 'Could not find a public key for Key ID (kid) "' . $tokenKid . '"' ); } return $token->verify( new RsSigner(), new Key( $this->jwks[ $tokenKid ] ) );