diff --git a/src/CachedKeySet.php b/src/CachedKeySet.php index 8e8e8d68..67b3db95 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', + ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::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', ExceptionCodes::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', ExceptionCodes::CACHED_KEY_EMPTY); } $keys = []; @@ -185,7 +194,7 @@ private function keyIdExists(string $keyId): bool $jwksResponse->getReasonPhrase(), $this->jwksUri, ), - $jwksResponse->getStatusCode() + ExceptionCodes::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', + ExceptionCodes::JWKS_URI_IS_EMPTY + ); } // ensure we do not have illegal characters diff --git a/src/ExceptionCodes.php b/src/ExceptionCodes.php new file mode 100644 index 00000000..57722f11 --- /dev/null +++ b/src/ExceptionCodes.php @@ -0,0 +1,69 @@ + $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', + ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::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"', + ExceptionCodes::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(), + ExceptionCodes::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', + ExceptionCodes::JWK_EC_D_IS_NOT_SET + ); } if (empty($jwk['crv'])) { - throw new UnexpectedValueException('crv not set'); + throw new UnexpectedValueException( + 'crv not set', + ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::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', ExceptionCodes::JWK_OKP_MISSING); } if (!isset($jwk['crv'])) { - throw new UnexpectedValueException('crv not set'); + throw new UnexpectedValueException('crv not set', ExceptionCodes::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', ExceptionCodes::JWT_CRV_UNSUPPORTED); } if (empty($jwk['x'])) { - throw new UnexpectedValueException('x not set'); + throw new UnexpectedValueException('x not set', ExceptionCodes::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 9100bf0f..d77151fa 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', + ExceptionCodes::KEY_NOT_EMPTY + ); } $tks = \explode('.', $jwt); if (\count($tks) !== 3) { - throw new UnexpectedValueException('Wrong number of segments'); + throw new UnexpectedValueException( + 'Wrong number of segments', + ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::PAYLOAD_NOT_JSON + ); } $sig = static::urlsafeB64Decode($cryptob64); if (empty($header->alg)) { - throw new UnexpectedValueException('Empty algorithm'); + throw new UnexpectedValueException( + 'Empty algorithm', + ExceptionCodes::EMPTY_ALGORITHM + ); } if (empty(static::$supported_algs[$header->alg])) { - throw new UnexpectedValueException('Algorithm not supported'); + throw new UnexpectedValueException( + 'Algorithm not supported', + ExceptionCodes::DECODE_ALGORITHM_NOT_SUPPORTED + ); } $key = self::getKey($keyOrKeyArray, property_exists($header, 'kid') ? $header->kid : null); @@ -140,21 +161,28 @@ 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', + ExceptionCodes::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', + ExceptionCodes::SIGNATURE_VERIFICATION_FAILED + ); } // Check 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) && floor($payload->nbf) > ($timestamp + static::$leeway)) { $ex = new BeforeValidException( - 'Cannot handle token with nbf prior to ' . \date(DateTime::ISO8601, (int) $payload->nbf) + 'Cannot handle token with nbf prior to ' . \date(DateTime::ISO8601, (int) $payload->nbf), + ExceptionCodes::NBF_PRIOR_TO_DATE ); $ex->setPayload($payload); throw $ex; @@ -165,7 +193,8 @@ public static function decode( // correctly used the nbf claim). 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::ISO8601, (int) $payload->iat) + 'Cannot handle token with iat prior to ' . \date(DateTime::ISO8601, (int) $payload->iat), + ExceptionCodes::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', ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::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', ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::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(), + ExceptionCodes::SODIUM_EXCEPTION, + $e + ); } } - throw new DomainException('Algorithm not supported'); + throw new DomainException( + 'Algorithm not supported', + ExceptionCodes::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', + ExceptionCodes::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(), + ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::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(), + ExceptionCodes::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', + ExceptionCodes::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', + ExceptionCodes::DECODED_JSON_IS_NULL + ); } return $obj; } @@ -396,10 +475,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', + ExceptionCodes::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', + ExceptionCodes::INVALID_JSON + ); } return $json; } @@ -470,7 +555,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', + ExceptionCodes::KID_IS_EMPTY + ); } if ($keyOrKeyArray instanceof CachedKeySet) { @@ -479,7 +567,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', + ExceptionCodes::KID_IS_INVALID + ); } return $keyOrKeyArray[$kid]; @@ -527,7 +618,8 @@ private static function handleJsonError(int $errno): void throw new DomainException( isset($messages[$errno]) ? $messages[$errno] - : 'Unknown JSON error: ' . $errno + : 'Unknown JSON error: ' . $errno, + ExceptionCodes::JSON_ERROR ); } diff --git a/src/Key.php b/src/Key.php index 00cf7f2e..56811446 100644 --- a/src/Key.php +++ b/src/Key.php @@ -28,15 +28,24 @@ public function __construct( && !$keyMaterial instanceof OpenSSLCertificate && !\is_resource($keyMaterial) ) { - throw new TypeError('Key material must be a string, resource, or OpenSSLAsymmetricKey'); + throw new TypeError( + 'Key material must be a string, resource, or OpenSSLAsymmetricKey', + ExceptionCodes::KEY_MATERIAL_IS_INVALID + ); } if (empty($keyMaterial)) { - throw new InvalidArgumentException('Key material must not be empty'); + throw new InvalidArgumentException( + 'Key material must not be empty', + ExceptionCodes::KEY_MATERIAL_IS_EMPTY + ); } if (empty($algorithm)) { - throw new InvalidArgumentException('Algorithm must not be empty'); + throw new InvalidArgumentException( + 'Algorithm must not be empty', + ExceptionCodes::KEY_ALGORITHM_IS_EMPTY + ); } // TODO: Remove in PHP 8.0 in favor of class constructor property promotion