diff --git a/src/BeforeValidException.php b/src/BeforeValidException.php index 595164bf..c8289c6b 100644 --- a/src/BeforeValidException.php +++ b/src/BeforeValidException.php @@ -2,7 +2,7 @@ namespace Firebase\JWT; -class BeforeValidException extends \UnexpectedValueException implements JWTExceptionWithPayloadInterface +class BeforeValidException extends \UnexpectedValueException implements JWTExceptionWithPayloadInterface, JwtExceptionInterface { private object $payload; diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index 8e8e8d68..d2ed7c94 100644 --- a/src/CachedKeySet.php +++ b/src/CachedKeySet.php @@ -101,7 +101,10 @@ public function __construct( public function offsetGet($keyId): Key { if (!$this->keyIdExists($keyId)) { - throw new OutOfBoundsException('Key ID not found'); + throw new OutOfBoundsException( + 'Key ID not found', + JwtExceptionInterface::KEY_ID_NOT_FOUND + ); } return JWK::parseKey($this->keySet[$keyId], $this->defaultAlg); } @@ -121,7 +124,10 @@ public function offsetExists($keyId): bool */ public function offsetSet($offset, $value): void { - throw new LogicException('Method not implemented'); + throw new LogicException( + 'Method not implemented', + JwtExceptionInterface::OFFSET_SET_METHOD_NOT_IMPLEMENTED + ); } /** @@ -129,7 +135,10 @@ public function offsetSet($offset, $value): void */ public function offsetUnset($offset): void { - throw new LogicException('Method not implemented'); + throw new LogicException( + 'Method not implemented', + JwtExceptionInterface::OFFSET_UNSET_METHOD_NOT_IMPLEMENTED + ); } /** @@ -140,11 +149,11 @@ private function formatJwksForCache(string $jwks): array $jwks = json_decode($jwks, true); if (!isset($jwks['keys'])) { - throw new UnexpectedValueException('"keys" member must exist in the JWK Set'); + throw new UnexpectedValueException('"keys" member must exist in the JWK Set', JwtExceptionInterface::CACHED_KEY_MISSING); } if (empty($jwks['keys'])) { - throw new InvalidArgumentException('JWK Set did not contain any keys'); + throw new InvalidArgumentException('JWK Set did not contain any keys', JwtExceptionInterface::CACHED_KEY_EMPTY); } $keys = []; @@ -185,7 +194,7 @@ private function keyIdExists(string $keyId): bool $jwksResponse->getReasonPhrase(), $this->jwksUri, ), - $jwksResponse->getStatusCode() + JwtExceptionInterface::CACHED_KEY_GET_JWK ); } $this->keySet = $this->formatJwksForCache((string) $jwksResponse->getBody()); @@ -243,7 +252,10 @@ private function getCacheItem(): CacheItemInterface private function setCacheKeys(): void { if (empty($this->jwksUri)) { - throw new RuntimeException('JWKS URI is empty'); + throw new RuntimeException( + 'JWKS URI is empty', + JwtExceptionInterface::JWKS_URI_IS_EMPTY + ); } // ensure we do not have illegal characters diff --git a/src/ExpiredException.php b/src/ExpiredException.php index 12fef094..99a059e9 100644 --- a/src/ExpiredException.php +++ b/src/ExpiredException.php @@ -2,7 +2,7 @@ namespace Firebase\JWT; -class ExpiredException extends \UnexpectedValueException implements JWTExceptionWithPayloadInterface +class ExpiredException extends \UnexpectedValueException implements JWTExceptionWithPayloadInterface, JwtExceptionInterface { private object $payload; diff --git a/src/JWK.php b/src/JWK.php index 405dcc49..112e54b9 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -57,11 +57,17 @@ public static function parseKeySet(array $jwks, ?string $defaultAlg = null): arr $keys = []; if (!isset($jwks['keys'])) { - throw new UnexpectedValueException('"keys" member must exist in the JWK Set'); + throw new UnexpectedValueException( + '"keys" member must exist in the JWK Set', + JwtExceptionInterface::JWK_MISSING_KEYS + ); } if (empty($jwks['keys'])) { - throw new InvalidArgumentException('JWK Set did not contain any keys'); + throw new InvalidArgumentException( + 'JWK Set did not contain any keys', + JwtExceptionInterface::JWT_KEYS_IS_EMPTY + ); } foreach ($jwks['keys'] as $k => $v) { @@ -72,7 +78,11 @@ public static function parseKeySet(array $jwks, ?string $defaultAlg = null): arr } if (0 === \count($keys)) { - throw new UnexpectedValueException('No supported algorithms found in JWK Set'); + throw new UnexpectedValueException( + 'No supported algorithms found in JWK Set', + JwtExceptionInterface::JWT_ALGORITHM_NOT_SUPPORTED + + ); } return $keys; @@ -96,11 +106,17 @@ public static function parseKeySet(array $jwks, ?string $defaultAlg = null): arr public static function parseKey(array $jwk, ?string $defaultAlg = null): ?Key { if (empty($jwk)) { - throw new InvalidArgumentException('JWK must not be empty'); + throw new InvalidArgumentException( + 'JWK must not be empty', + JwtExceptionInterface::JWK_IS_EMPTY + ); } if (!isset($jwk['kty'])) { - throw new UnexpectedValueException('JWK must contain a "kty" parameter'); + throw new UnexpectedValueException( + 'JWK must contain a "kty" parameter', + JwtExceptionInterface::JWT_MISSING_KTY_PARAMETER + ); } if (!isset($jwk['alg'])) { @@ -109,7 +125,10 @@ public static function parseKey(array $jwk, ?string $defaultAlg = null): ?Key // for parsing in this library. Use the $defaultAlg parameter when parsing the // key set in order to prevent this error. // @see https://datatracker.ietf.org/doc/html/rfc7517#section-4.4 - throw new UnexpectedValueException('JWK must contain an "alg" parameter'); + throw new UnexpectedValueException( + 'JWK must contain an "alg" parameter', + JwtExceptionInterface::JWT_MISSING_ALG_PARAMETER + ); } $jwk['alg'] = $defaultAlg; } @@ -117,36 +136,55 @@ public static function parseKey(array $jwk, ?string $defaultAlg = null): ?Key switch ($jwk['kty']) { case 'RSA': if (!empty($jwk['d'])) { - throw new UnexpectedValueException('RSA private keys are not supported'); + throw new UnexpectedValueException( + 'RSA private keys are not supported', + JwtExceptionInterface::JWT_RSA_KEYS_NOT_SUPPORTED + ); } if (!isset($jwk['n']) || !isset($jwk['e'])) { - throw new UnexpectedValueException('RSA keys must contain values for both "n" and "e"'); + throw new UnexpectedValueException( + 'RSA keys must contain values for both "n" and "e"', + JwtExceptionInterface::JWT_RSA_KEYS_MISSING_N_AND_E + ); } $pem = self::createPemFromModulusAndExponent($jwk['n'], $jwk['e']); $publicKey = \openssl_pkey_get_public($pem); if (false === $publicKey) { throw new DomainException( - 'OpenSSL error: ' . \openssl_error_string() + 'OpenSSL error: ' . \openssl_error_string(), + JwtExceptionInterface::JWT_OPEN_SSL_ERROR ); } return new Key($publicKey, $jwk['alg']); case 'EC': if (isset($jwk['d'])) { // The key is actually a private key - throw new UnexpectedValueException('Key data must be for a public key'); + throw new UnexpectedValueException( + 'Key data must be for a public key', + JwtExceptionInterface::JWK_EC_D_IS_NOT_SET + ); } if (empty($jwk['crv'])) { - throw new UnexpectedValueException('crv not set'); + throw new UnexpectedValueException( + 'crv not set', + JwtExceptionInterface::JWT_EC_CRV_IS_EMPTY + ); } if (!isset(self::EC_CURVES[$jwk['crv']])) { - throw new DomainException('Unrecognised or unsupported EC curve'); + throw new DomainException( + 'Unrecognised or unsupported EC curve', + JwtExceptionInterface::JWK_UNSUPPORTED_EC_CURVE + ); } if (empty($jwk['x']) || empty($jwk['y'])) { - throw new UnexpectedValueException('x and y not set'); + throw new UnexpectedValueException( + 'x and y not set', + JwtExceptionInterface::JWT_X_AND_Y_ARE_EMPTY + ); } $publicKey = self::createPemFromCrvAndXYCoordinates($jwk['crv'], $jwk['x'], $jwk['y']); @@ -154,19 +192,19 @@ public static function parseKey(array $jwk, ?string $defaultAlg = null): ?Key case 'OKP': if (isset($jwk['d'])) { // The key is actually a private key - throw new UnexpectedValueException('Key data must be for a public key'); + throw new UnexpectedValueException('Key data must be for a public key', JwtExceptionInterface::JWK_OKP_MISSING); } if (!isset($jwk['crv'])) { - throw new UnexpectedValueException('crv not set'); + throw new UnexpectedValueException('crv not set', JwtExceptionInterface::JWT_CRV_MISSING); } if (empty(self::OKP_SUBTYPES[$jwk['crv']])) { - throw new DomainException('Unrecognised or unsupported OKP key subtype'); + throw new DomainException('Unrecognised or unsupported OKP key subtype', JwtExceptionInterface::JWT_CRV_UNSUPPORTED); } if (empty($jwk['x'])) { - throw new UnexpectedValueException('x not set'); + throw new UnexpectedValueException('x not set', JwtExceptionInterface::JWT_X_MISSING); } // This library works internally with EdDSA keys (Ed25519) encoded in standard base64. diff --git a/src/JWT.php b/src/JWT.php index 37a9e0e6..85713983 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -102,37 +102,58 @@ public static function decode( $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; if (empty($keyOrKeyArray)) { - throw new InvalidArgumentException('Key may not be empty'); + throw new InvalidArgumentException( + 'Key may not be empty', + JwtExceptionInterface::KEY_NOT_EMPTY + ); } $tks = \explode('.', $jwt); if (\count($tks) !== 3) { - throw new UnexpectedValueException('Wrong number of segments'); + throw new UnexpectedValueException( + 'Wrong number of segments', + JwtExceptionInterface::WRONG_NUMBER_OF_SEGMENTS + ); } list($headb64, $bodyb64, $cryptob64) = $tks; $headerRaw = static::urlsafeB64Decode($headb64); if (null === ($header = static::jsonDecode($headerRaw))) { - throw new UnexpectedValueException('Invalid header encoding'); + throw new UnexpectedValueException( + 'Invalid header encoding', + JwtExceptionInterface::INVALID_HEADER_ENCODING + ); } if ($headers !== null) { $headers = $header; } $payloadRaw = static::urlsafeB64Decode($bodyb64); if (null === ($payload = static::jsonDecode($payloadRaw))) { - throw new UnexpectedValueException('Invalid claims encoding'); + throw new UnexpectedValueException( + 'Invalid claims encoding', + JwtExceptionInterface::INVALID_CLAIMS_ENCODING + ); } if (\is_array($payload)) { // prevent PHP Fatal Error in edge-cases when payload is empty array $payload = (object) $payload; } if (!$payload instanceof stdClass) { - throw new UnexpectedValueException('Payload must be a JSON object'); + throw new UnexpectedValueException( + 'Payload must be a JSON object', + JwtExceptionInterface::PAYLOAD_NOT_JSON + ); } $sig = static::urlsafeB64Decode($cryptob64); if (empty($header->alg)) { - throw new UnexpectedValueException('Empty algorithm'); + throw new UnexpectedValueException( + 'Empty algorithm', + JwtExceptionInterface::EMPTY_ALGORITHM + ); } if (empty(static::$supported_algs[$header->alg])) { - throw new UnexpectedValueException('Algorithm not supported'); + throw new UnexpectedValueException( + 'Algorithm not supported', + JwtExceptionInterface::DECODE_ALGORITHM_NOT_SUPPORTED + ); } $key = self::getKey($keyOrKeyArray, property_exists($header, 'kid') ? $header->kid : null); @@ -140,14 +161,20 @@ public static function decode( // Check the algorithm if (!self::constantTimeEquals($key->getAlgorithm(), $header->alg)) { // See issue #351 - throw new UnexpectedValueException('Incorrect key for this algorithm'); + throw new UnexpectedValueException( + 'Incorrect key for this algorithm', + JwtExceptionInterface::INCORRECT_KEY_FOR_ALGORITHM + ); } if (\in_array($header->alg, ['ES256', 'ES256K', 'ES384'], true)) { // OpenSSL expects an ASN.1 DER sequence for ES256/ES256K/ES384 signatures $sig = self::signatureToDER($sig); } if (!self::verify("{$headb64}.{$bodyb64}", $sig, $key->getKeyMaterial(), $header->alg)) { - throw new SignatureInvalidException('Signature verification failed'); + throw new SignatureInvalidException( + 'Signature verification failed', + JwtExceptionInterface::SIGNATURE_VERIFICATION_FAILED + ); } // Check the nbf if it is defined. This is the time that the @@ -155,6 +182,7 @@ public static function decode( if (isset($payload->nbf) && floor($payload->nbf) > ($timestamp + static::$leeway)) { $ex = new BeforeValidException( 'Cannot handle token with nbf prior to ' . \date(DateTime::ATOM, (int) floor($payload->nbf)) + JwtExceptionInterface::NBF_PRIOR_TO_DATE ); $ex->setPayload($payload); throw $ex; @@ -166,6 +194,7 @@ public static function decode( if (!isset($payload->nbf) && isset($payload->iat) && floor($payload->iat) > ($timestamp + static::$leeway)) { $ex = new BeforeValidException( 'Cannot handle token with iat prior to ' . \date(DateTime::ATOM, (int) floor($payload->iat)) + JwtExceptionInterface::IAT_PRIOR_TO_DATE ); $ex->setPayload($payload); throw $ex; @@ -173,7 +202,7 @@ public static function decode( // Check if this token has expired. if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) { - $ex = new ExpiredException('Expired token'); + $ex = new ExpiredException('Expired token', JwtExceptionInterface::TOKEN_EXPIRED); $ex->setPayload($payload); throw $ex; } @@ -240,23 +269,32 @@ public static function sign( string $alg ): string { if (empty(static::$supported_algs[$alg])) { - throw new DomainException('Algorithm not supported'); + throw new DomainException( + 'Algorithm not supported', + JwtExceptionInterface::SIGN_ALGORITHM_NOT_SUPPORTED + ); } list($function, $algorithm) = static::$supported_algs[$alg]; switch ($function) { case 'hash_hmac': if (!\is_string($key)) { - throw new InvalidArgumentException('key must be a string when using hmac'); + throw new InvalidArgumentException( + 'key must be a string when using hmac', + JwtExceptionInterface::KEY_IS_NOT_STRING + ); } return \hash_hmac($algorithm, $msg, $key, true); case 'openssl': $signature = ''; if (!\is_resource($key) && !openssl_pkey_get_private($key)) { - throw new DomainException('OpenSSL unable to validate key'); + throw new DomainException('OpenSSL unable to validate key', JwtExceptionInterface::OPENSSL_SIGNATURE); } $success = \openssl_sign($msg, $signature, $key, $algorithm); // @phpstan-ignore-line if (!$success) { - throw new DomainException('OpenSSL unable to sign data'); + throw new DomainException( + 'OpenSSL unable to sign data', + JwtExceptionInterface::OPENSSL_CAN_NOT_SIGN_DATA + ); } if ($alg === 'ES256' || $alg === 'ES256K') { $signature = self::signatureFromDER($signature, 256); @@ -266,25 +304,40 @@ public static function sign( return $signature; case 'sodium_crypto': if (!\function_exists('sodium_crypto_sign_detached')) { - throw new DomainException('libsodium is not available'); + throw new DomainException('libsodium is not available', + JwtExceptionInterface::SODIUM_FUNC_DOES_NOT_EXIST + ); } if (!\is_string($key)) { - throw new InvalidArgumentException('key must be a string when using EdDSA'); + throw new InvalidArgumentException( + 'key must be a string when using EdDSA', + JwtExceptionInterface::SODIUM_KEY_IS_NOT_STRING + ); } try { // The last non-empty line is used as the key. $lines = array_filter(explode("\n", $key)); $key = base64_decode((string) end($lines)); if (\strlen($key) === 0) { - throw new DomainException('Key cannot be empty string'); + throw new DomainException( + 'Key cannot be empty string', + JwtExceptionInterface::SODIUM_KEY_LENGTH_ZERO + ); } return sodium_crypto_sign_detached($msg, $key); } catch (Exception $e) { - throw new DomainException($e->getMessage(), 0, $e); + throw new DomainException( + $e->getMessage(), + JwtExceptionInterface::SODIUM_EXCEPTION, + $e + ); } } - throw new DomainException('Algorithm not supported'); + throw new DomainException( + 'Algorithm not supported', + JwtExceptionInterface::SIGN_GENERAL_EXCEPTION + ); } /** @@ -307,7 +360,10 @@ private static function verify( string $alg ): bool { if (empty(static::$supported_algs[$alg])) { - throw new DomainException('Algorithm not supported'); + throw new DomainException( + 'Algorithm not supported', + JwtExceptionInterface::VERIFY_ALGORITHM_NOT_SUPPORTED + ); } list($function, $algorithm) = static::$supported_algs[$alg]; @@ -322,33 +378,53 @@ private static function verify( } // returns 1 on success, 0 on failure, -1 on error. throw new DomainException( - 'OpenSSL error: ' . \openssl_error_string() + 'OpenSSL error: ' . \openssl_error_string(), + JwtExceptionInterface::VERIFY_OPEN_SSL_ERROR ); case 'sodium_crypto': if (!\function_exists('sodium_crypto_sign_verify_detached')) { - throw new DomainException('libsodium is not available'); + throw new DomainException( + 'libsodium is not available', + JwtExceptionInterface::VERIFY_SODIUM_NOT_AVAILABLE + ); } if (!\is_string($keyMaterial)) { - throw new InvalidArgumentException('key must be a string when using EdDSA'); + throw new InvalidArgumentException( + 'key must be a string when using EdDSA', + JwtExceptionInterface::VERIFY_KEY_MATERIAL_IS_NOT_STRING + ); } try { // The last non-empty line is used as the key. $lines = array_filter(explode("\n", $keyMaterial)); $key = base64_decode((string) end($lines)); if (\strlen($key) === 0) { - throw new DomainException('Key cannot be empty string'); + throw new DomainException( + 'Key cannot be empty string', + JwtExceptionInterface::SODIUM_VERIFY_KEY_LENGTH_ZERO + ); } if (\strlen($signature) === 0) { - throw new DomainException('Signature cannot be empty string'); + throw new DomainException( + 'Signature cannot be empty string', + JwtExceptionInterface::SODIUM_VERIFY_SIGNATURE_EMPTY + ); } return sodium_crypto_sign_verify_detached($signature, $msg, $key); } catch (Exception $e) { - throw new DomainException($e->getMessage(), 0, $e); + throw new DomainException( + $e->getMessage(), + JwtExceptionInterface::VERIFY_SODIUM_EXCEPTION, + $e + ); } case 'hash_hmac': default: if (!\is_string($keyMaterial)) { - throw new InvalidArgumentException('key must be a string when using hmac'); + throw new InvalidArgumentException( + 'key must be a string when using hmac', + JwtExceptionInterface::VERIFY_KEY_IS_NOT_STRING + ); } $hash = \hash_hmac($algorithm, $msg, $keyMaterial, true); return self::constantTimeEquals($hash, $signature); @@ -371,7 +447,10 @@ public static function jsonDecode(string $input) if ($errno = \json_last_error()) { self::handleJsonError($errno); } elseif ($obj === null && $input !== 'null') { - throw new DomainException('Null result with non-null input'); + throw new DomainException( + 'Null result with non-null input', + JwtExceptionInterface::DECODED_JSON_IS_NULL + ); } return $obj; } @@ -391,10 +470,16 @@ public static function jsonEncode(array $input): string if ($errno = \json_last_error()) { self::handleJsonError($errno); } elseif ($json === 'null') { - throw new DomainException('Null result with non-null input'); + throw new DomainException( + 'Null result with non-null input', + JwtExceptionInterface::ENCODED_JSON_IS_NULL + ); } if ($json === false) { - throw new DomainException('Provided object could not be encoded to valid JSON'); + throw new DomainException( + 'Provided object could not be encoded to valid JSON', + JwtExceptionInterface::INVALID_JSON + ); } return $json; } @@ -465,7 +550,10 @@ private static function getKey( } if (empty($kid) && $kid !== '0') { - throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); + throw new UnexpectedValueException( + '"kid" empty, unable to lookup correct key', + JwtExceptionInterface::KID_IS_EMPTY + ); } if ($keyOrKeyArray instanceof CachedKeySet) { @@ -474,7 +562,10 @@ private static function getKey( } if (!isset($keyOrKeyArray[$kid])) { - throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); + throw new UnexpectedValueException( + '"kid" invalid, unable to lookup correct key', + JwtExceptionInterface::KID_IS_INVALID + ); } return $keyOrKeyArray[$kid]; @@ -522,7 +613,8 @@ private static function handleJsonError(int $errno): void throw new DomainException( isset($messages[$errno]) ? $messages[$errno] - : 'Unknown JSON error: ' . $errno + : 'Unknown JSON error: ' . $errno, + JwtExceptionInterface::JSON_ERROR ); } diff --git a/src/JwtExceptionInterface.php b/src/JwtExceptionInterface.php new file mode 100644 index 00000000..07c2d3b6 --- /dev/null +++ b/src/JwtExceptionInterface.php @@ -0,0 +1,69 @@ +expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('HTTP Error: 404 URL not found'); - $this->expectExceptionCode(404); + $this->expectExceptionCode(58); $response = $this->prophesize('Psr\Http\Message\ResponseInterface'); $response->getStatusCode()