diff --git a/README.md b/README.md index d93e58f..185e386 100644 --- a/README.md +++ b/README.md @@ -35,20 +35,41 @@ and run `composer update` or alternatively run `composer require bizley/jwt:^4.0 ## Basic usage -Add `jwt` component to your configuration file: +Add `jwt` component to your configuration file. + +If your application is both the issuer and the consumer of JWT (the common case, a.k.a. Standard version) +use `bizley\jwt\Jwt` component: ```php [ 'components' => [ 'jwt' => [ 'class' => \bizley\jwt\Jwt::class, - 'signer' => ... // Signer ID, or signer object, or signer configuration - 'signingKey' => ... // Secret key string or path to the signing key file + 'signer' => ... // Signer ID, or signer object, or signer configuration, see "Available signers" below + 'signingKey' => ... // Secret key string or path to the signing key file, see "Keys" below + // ... any additional configuration here + ], + ], +], +``` + +If your application just needs some special JWT tools (like validator or parser, a.k.a. Toolset version) +use `bizley\jwt\JwtTools` component: + +```php +[ + 'components' => [ + 'jwt' => [ + 'class' => \bizley\jwt\JwtTools::class, + // ... any additional configuration here ], ], ], ``` +Of course, if you are already using the Standard version component, you don't need to define the Toolset version +component, since the former already provides all the tools. + If you are struggling with the concept of API JWT, here is an [EXAMPLE](INSTRUCTION.md) of how to quickly put all pieces together. @@ -65,10 +86,10 @@ Asymmetric: Signer IDs are available as constants (like Jwt::HS256). -You can also provide your own signer, either as an instance of Lcobucci\JWT\Signer or by adding its config to `signers` +You can also provide your own signer, either as an instance of `Lcobucci\JWT\Signer` or by adding its config to `signers` and `algorithmTypes` and using its ID for `signer`. -> As stated in lcobucci/jwt documentation: Although BLAKE2B is fantastic due to its performance, it's not JWT standard +> As stated in `lcobucci/jwt` documentation: Although BLAKE2B is fantastic due to its performance, it's not JWT standard > and won't necessarily be offered by other libraries. ### Note on signers and minimum bits requirement @@ -79,7 +100,7 @@ the `InvalidKeyProvided` is thrown. ### Keys For symmetric signers `signingKey` is required. For asymmetric ones you also need to set `verifyingKey`. Keys can be -provided as simple strings, configuration arrays, or instances of Lcobucci\JWT\Signer\Key. +provided as simple strings, configuration arrays, or instances of `Lcobucci\JWT\Signer\Key`. Configuration array can be as the following: @@ -91,10 +112,10 @@ Configuration array can be as the following: ] ``` -- key (Jwt::KEY) - _string_, default `''`, -- passphrase (Jwt::PASSPHRASE) - _string_, default `''`, -- method (Jwt::METHOD) - _string_, default `Jwt::METHOD_PLAIN`, - available: `Jwt::METHOD_PLAIN`, `Jwt::METHOD_BASE64`, `Jwt::METHOD_FILE` +- key (`bizley\jwt\Jwt::KEY`) - _string_, default `''`, +- passphrase (`bizley\jwt\Jwt::PASSPHRASE`) - _string_, default `''`, +- method (`bizley\jwt\Jwt::METHOD`) - _string_, default `bizley\jwt\Jwt::METHOD_PLAIN`, + available: `bizley\jwt\Jwt::METHOD_PLAIN`, `bizley\jwt\Jwt::METHOD_BASE64`, `bizley\jwt\Jwt::METHOD_FILE` (see https://lcobucci-jwt.readthedocs.io/en/latest/configuration/) Simple string keys are shortcuts to the following array configs: @@ -103,7 +124,7 @@ Simple string keys are shortcuts to the following array configs: [ 'key' => /* given key itself */, 'passphrase' => '', - 'method' => Jwt::METHOD_FILE, + 'method' => \bizley\jwt\Jwt::METHOD_FILE, ] ``` Detecting `@` at the beginning assumes Yii alias has been provided, so it will be resolved with `Yii::getAlias()`. @@ -113,15 +134,17 @@ Simple string keys are shortcuts to the following array configs: [ 'key' => /* given key itself */, 'passphrase' => '', - 'method' => Jwt::METHOD_PLAIN, + 'method' => \bizley\jwt\Jwt::METHOD_PLAIN, ] ``` ### Issuing a token example: +Standard version: + ```php $now = new \DateTimeImmutable(); -/** @var \Lcobucci\JWT\Token\Plain $token */ +/** @var \Lcobucci\JWT\Token\UnencryptedToken $token */ $token = Yii::$app->jwt->getBuilder() // Configures the issuer (iss claim) ->issuedBy('http://example.com') @@ -147,12 +170,33 @@ $token = Yii::$app->jwt->getBuilder() $tokenString = $token->toString(); ``` +The same in Toolset version: + +```php +$now = new \DateTimeImmutable(); +/** @var \Lcobucci\JWT\Token\UnencryptedToken $token */ +$token = Yii::$app->jwt->getBuilder() + ->issuedBy('http://example.com') + ->permittedFor('http://example.org') + ->identifiedBy('4f1g23a12aa') + ->issuedAt($now) + ->canOnlyBeUsedAfter($now->modify('+1 minute')) + ->expiresAt($now->modify('+1 hour')) + ->withClaim('uid', 1) + ->withHeader('foo', 'bar') + ->getToken( + Yii::$app->jwt->buildSigner(/* signer definition */), + Yii::$app->jwt->buildKey(/* signing key definition */) + ); +$tokenString = $token->toString(); +``` + See https://lcobucci-jwt.readthedocs.io/en/latest/issuing-tokens/ for more info. ### Parsing a token ```php -/** @var string $jwt */ +/** @var non-empty-string $jwt */ /** @var \Lcobucci\JWT\Token $token */ $token = Yii::$app->jwt->parse($jwt); ``` @@ -165,7 +209,7 @@ You can validate a token or perform an assertion on it (see https://lcobucci-jwt For validation use: ```php -/** @var \Lcobucci\JWT\Token | string $token */ +/** @var \Lcobucci\JWT\Token | non-empty-string $token */ /** @var bool $result */ $result = Yii::$app->jwt->validate($token); ``` @@ -179,7 +223,7 @@ Yii::$app->jwt->assert($token); You **MUST** provide at least one constraint, otherwise `Lcobucci\JWT\Validation\NoConstraintsGiven` exception will be thrown. There are several ways to provide constraints: -- directly: +- directly (Standard version only): ```php Yii::$app->jwt->getConfiguration()->setValidationConstraints(/* constaints here */); ``` @@ -195,7 +239,7 @@ thrown. There are several ways to provide constraints: or anonymous function that can be resolved as array of Constraint instances with signature - `function(\bizley\jwt\Jwt $jwt)` where $jwt will be an instance of this component + `function(\bizley\jwt\Jwt|\bizley\jwt\JwtTools $jwt)` where $jwt will be an instance of used component */, ] ``` @@ -224,7 +268,8 @@ class ExampleController extends Controller ``` There are special options available: -- jwt - _string_ ID of component (default with `'jwt'`), component configuration _array_, or an instance of `bizley\jwt\Jwt`, +- jwt - _string_ ID of component (default with `'jwt'`), component configuration _array_, or an instance of `bizley\jwt\Jwt` + or `bizley\jwt\JwtTools`, - auth - callable or `null` (default) - anonymous function with signature `function (\Lcobucci\JWT\Token $token)` that should return identity of user authenticated with the JWT payload information. If $auth is not provided method `yii\web\User::loginByAccessToken()` will be called instead. diff --git a/infection.json.dist b/infection.json.dist index dca1b08..d7e8cb4 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -14,7 +14,7 @@ "@default": true, "MethodCallRemoval": { "ignore": [ - "bizley\\jwt\\Jwt::init::186", + "bizley\\jwt\\Jwt::init::124", "bizley\\jwt\\JwtHttpBearerAuth::init::77" ] } diff --git a/src/Jwt.php b/src/Jwt.php index c647ada..a21a01a 100644 --- a/src/Jwt.php +++ b/src/Jwt.php @@ -7,36 +7,24 @@ use Lcobucci\JWT\Builder; use Lcobucci\JWT\ClaimsFormatter; use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Decoder; -use Lcobucci\JWT\Encoder; -use Lcobucci\JWT\Encoding\CannotDecodeContent; -use Lcobucci\JWT\Encoding\JoseEncoder; use Lcobucci\JWT\Parser; use Lcobucci\JWT\Signer; use Lcobucci\JWT\Token; use Lcobucci\JWT\Validation; -use Yii; -use yii\base\Component; +use Lcobucci\JWT\Validator; use yii\base\InvalidConfigException; -use yii\di\Instance; - -use function array_keys; -use function count; -use function in_array; -use function is_array; -use function is_callable; -use function is_string; -use function reset; -use function str_starts_with; /** * JSON Web Token implementation based on lcobucci/jwt library v5. * @see https://github.com/lcobucci/jwt * + * This implementation is based on the \Lcobucci\JWT\Configuration setup which requires both signing and verifying keys + * to be defined (the standard way). If you need only some JWT tools, please use \bizley\jwt\JwtTools directly. + * * @author Paweł Bizley Brzozowski since 2.0 (fork) * @author Dmitriy Demin original package */ -class Jwt extends Component +class Jwt extends JwtTools { public const HS256 = 'HS256'; public const HS384 = 'HS384'; @@ -50,8 +38,6 @@ class Jwt extends Component public const EDDSA = 'EdDSA'; public const BLAKE2B = 'BLAKE2B'; - public const STORE_IN_MEMORY = 'in_memory'; - public const METHOD_PLAIN = 'plain'; public const METHOD_BASE64 = 'base64'; public const METHOD_FILE = 'file'; @@ -60,7 +46,6 @@ class Jwt extends Component public const ASYMMETRIC = 'asymmetric'; public const KEY = 'key'; - public const STORE = 'store'; public const METHOD = 'method'; public const PASSPHRASE = 'passphrase'; @@ -79,7 +64,7 @@ class Jwt extends Component * 'method' => Jwt::METHOD_PLAIN, * 'passphrase' => '', * ] - * In case a simple string is provided and it does start with 'file://' (direct file path) or '@' (Yii alias) + * In case a simple string is provided, and it does start with 'file://' (direct file path) or '@' (Yii alias) * the following configuration is assumed: * [ * 'key' => // the original given value, @@ -107,27 +92,6 @@ class Jwt extends Component */ public $signer = ''; - /** - * @var array Default signers configuration. When instantiated it will use selected array to - * spread into `Yii::createObject($type, array $params = [])` method so the first array element is $type, and - * the second is $params. - * Since 3.0.0 configuration is done using arrays. - * @since 2.0.0 - */ - public array $signers = [ - self::HS256 => [Signer\Hmac\Sha256::class], - self::HS384 => [Signer\Hmac\Sha384::class], - self::HS512 => [Signer\Hmac\Sha512::class], - self::RS256 => [Signer\Rsa\Sha256::class], - self::RS384 => [Signer\Rsa\Sha384::class], - self::RS512 => [Signer\Rsa\Sha512::class], - self::ES256 => [Signer\Ecdsa\Sha256::class], - self::ES384 => [Signer\Ecdsa\Sha384::class], - self::ES512 => [Signer\Ecdsa\Sha512::class], - self::EDDSA => [Signer\Eddsa::class], - self::BLAKE2B => [Signer\Blake2b::class], - ]; - /** * @var array> Algorithm types. * @since 3.0.0 @@ -150,32 +114,6 @@ class Jwt extends Component ], ]; - /** - * @var string|array|Encoder|null Custom encoder. - * It can be component's ID, configuration array, or instance of Encoder. - * In case it's not an instance, it must be resolvable to an Encoder's instance. - * @since 3.0.0 - */ - public $encoder; - - /** - * @var string|array|Decoder|null Custom decoder. - * It can be component's ID, configuration array, or instance of Decoder. - * In case it's not an instance, it must be resolvable to a Decoder's instance. - * @since 3.0.0 - */ - public $decoder; - - /** - * @var array|(callable(): mixed)|string>|(callable(): mixed)|null List of constraints that - * will be used to validate against or an anonymous function that can be resolved as such list. The signature of - * the function should be `function(\bizley\jwt\Jwt $jwt)` where $jwt will be an instance of this component. - * For the constraints you can use instances of Lcobucci\JWT\Validation\Constraint or configuration arrays to be - * resolved as such. - * @since 3.0.0 - */ - public $validationConstraints; - private ?Configuration $configuration = null; /** @@ -189,18 +127,18 @@ public function init(): void if ($this->signer instanceof Signer) { $signerId = $this->signer->algorithmId(); } - if (in_array($signerId, $this->algorithmTypes[self::SYMMETRIC], true)) { + if (\in_array($signerId, $this->algorithmTypes[self::SYMMETRIC], true)) { $this->configuration = Configuration::forSymmetricSigner( - $this->prepareSigner($this->signer), - $this->prepareKey($this->signingKey), + $this->buildSigner($this->signer), + $this->buildKey($this->signingKey), $this->prepareEncoder(), $this->prepareDecoder() ); - } elseif (in_array($signerId, $this->algorithmTypes[self::ASYMMETRIC], true)) { + } elseif (\in_array($signerId, $this->algorithmTypes[self::ASYMMETRIC], true)) { $this->configuration = Configuration::forAsymmetricSigner( - $this->prepareSigner($this->signer), - $this->prepareKey($this->signingKey), - $this->prepareKey($this->verifyingKey), + $this->buildSigner($this->signer), + $this->buildKey($this->signingKey), + $this->buildKey($this->verifyingKey), $this->prepareEncoder(), $this->prepareDecoder() ); @@ -209,22 +147,6 @@ public function init(): void } } - /** - * @param array|(callable(): mixed)|string> $config - * @return object - * @throws InvalidConfigException - */ - private function buildObjectFromArray(array $config): object - { - $keys = array_keys($config); - if (is_string(reset($keys))) { - // most probably Yii-style config - return Yii::createObject($config); - } - - return Yii::createObject(...$config); - } - /** * @throws InvalidConfigException * @since 3.0.0 @@ -239,7 +161,6 @@ public function getConfiguration(): Configuration } /** - * Since 3.0.0 this method is using different signature. * Please note that since 4.0.0 Builder object is immutable. * @see https://lcobucci-jwt.readthedocs.io/en/latest/issuing-tokens/ for details of using the builder. * @throws InvalidConfigException @@ -250,7 +171,6 @@ public function getBuilder(?ClaimsFormatter $claimFormatter = null): Builder } /** - * Since 3.0.0 this method is using different signature. * @see https://lcobucci-jwt.readthedocs.io/en/latest/parsing-tokens/ for details of using the parser. * @throws InvalidConfigException */ @@ -260,16 +180,12 @@ public function getParser(): Parser } /** - * @param non-empty-string $jwt - * @throws CannotDecodeContent When something goes wrong while decoding. - * @throws Token\InvalidTokenStructure When token string structure is invalid. - * @throws Token\UnsupportedHeaderFound When parsed token has an unsupported header. + * @see https://lcobucci-jwt.readthedocs.io/en/stable/validating-tokens/ for details of using the validator. * @throws InvalidConfigException - * @since 3.0.0 */ - public function parse(string $jwt): Token + public function getValidator(): Validator { - return $this->getParser()->parse($jwt); + return $this->getConfiguration()->validator(); } /** @@ -304,153 +220,17 @@ public function validate($jwt): bool return $configuration->validator()->validate($token, ...$constraints); } - /** - * Prepares key based on the definition. - * @param string|array|Signer\Key $key - * @return Signer\Key - * @throws InvalidConfigException - * @since 2.0.0 - * Since 3.0.0 this method is private and using different signature. - */ - private function prepareKey($key): Signer\Key - { - if ($key instanceof Signer\Key) { - return $key; - } - - if (is_string($key)) { - if ($key === '') { - throw new InvalidConfigException('Empty string used as a key configuration!'); - } - if (str_starts_with($key, '@')) { - $keyConfig = [ - self::KEY => Yii::getAlias($key), - self::METHOD => self::METHOD_FILE, - ]; - } elseif (str_starts_with($key, 'file://')) { - $keyConfig = [ - self::KEY => $key, - self::METHOD => self::METHOD_FILE, - ]; - } else { - $keyConfig = [ - self::KEY => $key, - self::METHOD => self::METHOD_PLAIN, - ]; - } - } elseif (is_array($key)) { - $keyConfig = $key; - } else { - throw new InvalidConfigException('Invalid key configuration!'); - } - - $value = $keyConfig[self::KEY] ?? ''; - $method = $keyConfig[self::METHOD] ?? self::METHOD_PLAIN; - $passphrase = $keyConfig[self::PASSPHRASE] ?? ''; - - if (!is_string($value) || $value === '') { - throw new InvalidConfigException('Invalid key value!'); - } - if (!in_array($method, [self::METHOD_PLAIN, self::METHOD_BASE64, self::METHOD_FILE], true)) { - throw new InvalidConfigException('Invalid key method!'); - } - if (!is_string($passphrase)) { - throw new InvalidConfigException('Invalid key passphrase!'); - } - - if ($method === self::METHOD_BASE64) { - return Signer\Key\InMemory::base64Encoded($value, $passphrase); - } - if ($method === self::METHOD_FILE) { - return Signer\Key\InMemory::file($value, $passphrase); - } - - return Signer\Key\InMemory::plainText($value, $passphrase); - } - - /** - * @param string|Signer $signer - * @return Signer - * @throws InvalidConfigException - */ - private function prepareSigner($signer): Signer - { - if ($signer instanceof Signer) { - return $signer; - } - - if (in_array($signer, [self::ES256, self::ES384, self::ES512], true)) { - Yii::$container->set(Signer\Ecdsa\SignatureConverter::class, Signer\Ecdsa\MultibyteStringConverter::class); - } - - /** @var Signer $signerInstance */ - $signerInstance = $this->buildObjectFromArray($this->signers[$signer]); - - return $signerInstance; - } - /** * @return Validation\Constraint[] * @throws InvalidConfigException */ - private function prepareValidationConstraints(): array + protected function prepareValidationConstraints(): array { $configuredConstraints = $this->getConfiguration()->validationConstraints(); - if (count($configuredConstraints)) { + if (\count($configuredConstraints)) { return $configuredConstraints; } - if (is_array($this->validationConstraints)) { - $constraints = []; - - foreach ($this->validationConstraints as $constraint) { - if ($constraint instanceof Validation\Constraint) { - $constraints[] = $constraint; - } else { - /** @var Validation\Constraint $constraintInstance */ - $constraintInstance = $this->buildObjectFromArray($constraint); - $constraints[] = $constraintInstance; - } - } - - return $constraints; - } - - if (is_callable($this->validationConstraints)) { - /** @phpstan-ignore-next-line */ - return call_user_func($this->validationConstraints, $this); - } - - return []; - } - - /** - * @throws InvalidConfigException - */ - private function prepareEncoder(): Encoder - { - if ($this->encoder === null) { - return new JoseEncoder(); - } - - /** @var Encoder $encoder */ - $encoder = Instance::ensure($this->encoder, Encoder::class); - - return $encoder; - } - - /** - * @throws InvalidConfigException - */ - private function prepareDecoder(): Decoder - { - if ($this->decoder === null) { - return new JoseEncoder(); - } - - /** @var Decoder $decoder */ - $decoder = Instance::ensure($this->decoder, Decoder::class); - - return $decoder; + return parent::prepareValidationConstraints(); } } diff --git a/src/JwtHttpBearerAuth.php b/src/JwtHttpBearerAuth.php index 6ccc51c..2517d59 100644 --- a/src/JwtHttpBearerAuth.php +++ b/src/JwtHttpBearerAuth.php @@ -43,7 +43,7 @@ class JwtHttpBearerAuth extends HttpBearerAuth { /** - * @var string|array|Jwt application component ID of the JWT handler, configuration array, + * @var string|array|JwtTools|Jwt application component ID of the JWT handler, configuration array, * or JWT handler object itself. By default, it assumes that component of ID "jwt" has been configured. */ public $jwt = 'jwt'; @@ -81,13 +81,13 @@ public function init(): void } } - private ?Jwt $JWTComponent = null; + private Jwt|JwtTools|null $JWTComponent = null; - public function getJwtComponent(): Jwt + public function getJwtComponent(): Jwt|JwtTools { if ($this->JWTComponent === null) { - /** @var Jwt $jwt */ - $jwt = Instance::ensure($this->jwt, Jwt::class); + /** @var Jwt|JwtTools $jwt */ + $jwt = Instance::ensure($this->jwt, JwtTools::class); $this->JWTComponent = $jwt; } diff --git a/src/JwtTools.php b/src/JwtTools.php new file mode 100644 index 0000000..200dd3b --- /dev/null +++ b/src/JwtTools.php @@ -0,0 +1,317 @@ + + * @since 4.1.0 + */ +class JwtTools extends Component +{ + /** + * @var array Default signers configuration. When instantiated it will use selected array to + * spread into `Yii::createObject($type, array $params = [])` method so the first array element is $type, and + * the second is $params. + */ + public array $signers = [ + Jwt::HS256 => [Signer\Hmac\Sha256::class], + Jwt::HS384 => [Signer\Hmac\Sha384::class], + Jwt::HS512 => [Signer\Hmac\Sha512::class], + Jwt::RS256 => [Signer\Rsa\Sha256::class], + Jwt::RS384 => [Signer\Rsa\Sha384::class], + Jwt::RS512 => [Signer\Rsa\Sha512::class], + Jwt::ES256 => [Signer\Ecdsa\Sha256::class], + Jwt::ES384 => [Signer\Ecdsa\Sha384::class], + Jwt::ES512 => [Signer\Ecdsa\Sha512::class], + Jwt::EDDSA => [Signer\Eddsa::class], + Jwt::BLAKE2B => [Signer\Blake2b::class], + ]; + + /** + * @var string|array|Encoder|null Custom encoder. + * It can be component's ID, configuration array, or instance of Encoder. + * In case it's not an instance, it must be resolvable to an Encoder's instance. + */ + public $encoder; + + /** + * @var string|array|Decoder|null Custom decoder. + * It can be component's ID, configuration array, or instance of Decoder. + * In case it's not an instance, it must be resolvable to a Decoder's instance. + */ + public $decoder; + + /** + * @var array|(callable(): mixed)|string>|(callable(): mixed)|null List of constraints that + * will be used to validate against or an anonymous function that can be resolved as such list. The signature of + * the function should be `function(\bizley\jwt\JwtTools|\bizley\jwt\Jwt $jwt)` where $jwt will be an instance of + * this component. + * For the constraints you can use instances of Lcobucci\JWT\Validation\Constraint or configuration arrays to be + * resolved as such. + */ + public $validationConstraints; + + /** + * @param array|(callable(): mixed)|string> $config + * @return object + * @throws InvalidConfigException + */ + private function buildObjectFromArray(array $config): object + { + $keys = \array_keys($config); + if (\is_string(\reset($keys))) { + // most probably Yii-style config + return Yii::createObject($config); + } + + return Yii::createObject(...$config); + } + + /** + * @see https://lcobucci-jwt.readthedocs.io/en/latest/issuing-tokens/ for details of using the builder. + * @throws InvalidConfigException + */ + public function getBuilder(?ClaimsFormatter $claimFormatter = null): Builder + { + return new Token\Builder($this->prepareEncoder(), $claimFormatter ?? ChainedFormatter::default()); + } + + /** + * @see https://lcobucci-jwt.readthedocs.io/en/latest/parsing-tokens/ for details of using the parser. + * @throws InvalidConfigException + */ + public function getParser(): Parser + { + return new Token\Parser($this->prepareDecoder()); + } + + /** + * @see https://lcobucci-jwt.readthedocs.io/en/stable/validating-tokens/ for details of using the validator. + */ + public function getValidator(): Validator + { + return new Validation\Validator(); + } + + /** + * @param non-empty-string $jwt + * @throws CannotDecodeContent When something goes wrong while decoding. + * @throws Token\InvalidTokenStructure When token string structure is invalid. + * @throws Token\UnsupportedHeaderFound When parsed token has an unsupported header. + * @throws InvalidConfigException + */ + public function parse(string $jwt): Token + { + return $this->getParser()->parse($jwt); + } + + /** + * This method goes through every single constraint in the set, groups all the violations, and throws an exception + * with the grouped violations. + * @param non-empty-string|Token $jwt JWT string or instance of Token + * @throws Validation\RequiredConstraintsViolated When constraint is violated + * @throws Validation\NoConstraintsGiven When no constraints are provided + * @throws InvalidConfigException + */ + public function assert($jwt): void + { + $token = $jwt instanceof Token ? $jwt : $this->parse($jwt); + $constraints = $this->prepareValidationConstraints(); + $this->getValidator()->assert($token, ...$constraints); + } + + /** + * This method return false on first constraint violation + * @param non-empty-string|Token $jwt JWT string or instance of Token + * @throws InvalidConfigException + */ + public function validate($jwt): bool + { + $token = $jwt instanceof Token ? $jwt : $this->parse($jwt); + $constraints = $this->prepareValidationConstraints(); + + return $this->getValidator()->validate($token, ...$constraints); + } + + /** + * Returns the key based on the definition. + * @param string|array|Signer\Key $key + * @return Signer\Key + * @throws InvalidConfigException + */ + public function buildKey($key): Signer\Key + { + if ($key instanceof Signer\Key) { + return $key; + } + + if (\is_string($key)) { + if ($key === '') { + throw new InvalidConfigException('Empty string used as a key configuration!'); + } + if (\str_starts_with($key, '@')) { + $keyConfig = [ + Jwt::KEY => Yii::getAlias($key), + Jwt::METHOD => Jwt::METHOD_FILE, + ]; + } elseif (\str_starts_with($key, 'file://')) { + $keyConfig = [ + Jwt::KEY => $key, + Jwt::METHOD => Jwt::METHOD_FILE, + ]; + } else { + $keyConfig = [ + Jwt::KEY => $key, + Jwt::METHOD => Jwt::METHOD_PLAIN, + ]; + } + } elseif (\is_array($key)) { + $keyConfig = $key; + } else { + throw new InvalidConfigException('Invalid key configuration!'); + } + + $value = $keyConfig[Jwt::KEY] ?? ''; + $method = $keyConfig[Jwt::METHOD] ?? Jwt::METHOD_PLAIN; + $passphrase = $keyConfig[Jwt::PASSPHRASE] ?? ''; + + if (!\is_string($value) || $value === '') { + throw new InvalidConfigException('Invalid key value!'); + } + if (!\in_array($method, [Jwt::METHOD_PLAIN, Jwt::METHOD_BASE64, Jwt::METHOD_FILE], true)) { + throw new InvalidConfigException('Invalid key method!'); + } + if (!\is_string($passphrase)) { + throw new InvalidConfigException('Invalid key passphrase!'); + } + + if ($method === Jwt::METHOD_BASE64) { + return Signer\Key\InMemory::base64Encoded($value, $passphrase); + } + if ($method === Jwt::METHOD_FILE) { + return Signer\Key\InMemory::file($value, $passphrase); + } + + return Signer\Key\InMemory::plainText($value, $passphrase); + } + + /** + * @param string|Signer $signer + * @return Signer + * @throws InvalidConfigException + */ + public function buildSigner($signer): Signer + { + if ($signer instanceof Signer) { + return $signer; + } + + if (!\array_key_exists($signer, $this->signers)) { + throw new InvalidConfigException('Invalid signer ID!'); + } + + if (\in_array($signer, [Jwt::ES256, Jwt::ES384, Jwt::ES512], true)) { + Yii::$container->set(Signer\Ecdsa\SignatureConverter::class, Signer\Ecdsa\MultibyteStringConverter::class); + } + + /** @var Signer $signerInstance */ + $signerInstance = $this->buildObjectFromArray($this->signers[$signer]); + + return $signerInstance; + } + + /** + * @return Validation\Constraint[] + * @throws InvalidConfigException + */ + protected function prepareValidationConstraints(): array + { + if (\is_array($this->validationConstraints)) { + $constraints = []; + + foreach ($this->validationConstraints as $constraint) { + if ($constraint instanceof Validation\Constraint) { + $constraints[] = $constraint; + } else { + /** @var Validation\Constraint $constraintInstance */ + $constraintInstance = $this->buildObjectFromArray($constraint); + $constraints[] = $constraintInstance; + } + } + + return $constraints; + } + + if (\is_callable($this->validationConstraints)) { + /** @phpstan-ignore-next-line */ + return \call_user_func($this->validationConstraints, $this); + } + + return []; + } + + private ?Encoder $builtEncoder = null; + + /** + * @throws InvalidConfigException + */ + protected function prepareEncoder(): Encoder + { + if ($this->builtEncoder === null) { + if ($this->encoder === null) { + $this->builtEncoder = new JoseEncoder(); + } else { + /** @var Encoder $encoder */ + $encoder = Instance::ensure($this->encoder, Encoder::class); + $this->builtEncoder = $encoder; + } + } + + return $this->builtEncoder; + } + + private ?Decoder $builtDecoder = null; + + /** + * @throws InvalidConfigException + */ + protected function prepareDecoder(): Decoder + { + if ($this->builtDecoder === null) { + if ($this->decoder === null) { + $this->builtDecoder = new JoseEncoder(); + } else { + /** @var Decoder $decoder */ + $decoder = Instance::ensure($this->decoder, Decoder::class); + $this->builtDecoder = $decoder; + } + } + + return $this->builtDecoder; + } +} diff --git a/tests/BearerTest.php b/tests/standard/BearerTest.php similarity index 99% rename from tests/BearerTest.php rename to tests/standard/BearerTest.php index 2cf8ec5..76527cf 100644 --- a/tests/BearerTest.php +++ b/tests/standard/BearerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace bizley\tests; +namespace bizley\tests\standard; use bizley\jwt\Jwt; use bizley\jwt\JwtHttpBearerAuth; diff --git a/tests/ConstraintsConfigTest.php b/tests/standard/ConstraintsConfigTest.php similarity index 98% rename from tests/ConstraintsConfigTest.php rename to tests/standard/ConstraintsConfigTest.php index 57017fa..741770c 100644 --- a/tests/ConstraintsConfigTest.php +++ b/tests/standard/ConstraintsConfigTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace bizley\tests; +namespace bizley\tests\standard; use bizley\jwt\Jwt; use bizley\tests\stubs\YiiConstraint; diff --git a/tests/JwtTest.php b/tests/standard/JwtTest.php similarity index 92% rename from tests/JwtTest.php rename to tests/standard/JwtTest.php index fff3d61..5147d96 100644 --- a/tests/JwtTest.php +++ b/tests/standard/JwtTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace bizley\tests; +namespace bizley\tests\standard; use bizley\jwt\Jwt; use bizley\tests\stubs\JwtStub; @@ -28,26 +28,6 @@ private function getJwt(): Jwt ); } - public function testAvailableSigners(): void - { - self::assertSame( - [ - Jwt::HS256 => [Signer\Hmac\Sha256::class], - Jwt::HS384 => [Signer\Hmac\Sha384::class], - Jwt::HS512 => [Signer\Hmac\Sha512::class], - Jwt::RS256 => [Signer\Rsa\Sha256::class], - Jwt::RS384 => [Signer\Rsa\Sha384::class], - Jwt::RS512 => [Signer\Rsa\Sha512::class], - Jwt::ES256 => [Signer\Ecdsa\Sha256::class], - Jwt::ES384 => [Signer\Ecdsa\Sha384::class], - Jwt::ES512 => [Signer\Ecdsa\Sha512::class], - Jwt::EDDSA => [Signer\Eddsa::class], - Jwt::BLAKE2B => [Signer\Blake2b::class], - ], - $this->getJwt()->signers, - ); - } - public function testAvailableAlgorithmTypes(): void { self::assertSame( @@ -242,7 +222,7 @@ public function testWrongFileKeyNameStartingCharacters(string $key): void public static function providerForRightKeyNames(): iterable { yield '@' => ['@bizley/tests/data/rs256.key']; - yield 'file://' => ['file://' . __DIR__ . '/data/rs256.key']; + yield 'file://' => ['file://' . __DIR__ . '/../data/rs256.key']; } /** diff --git a/tests/SignerTest.php b/tests/standard/SignerTest.php similarity index 94% rename from tests/SignerTest.php rename to tests/standard/SignerTest.php index 3391585..ed3398e 100644 --- a/tests/SignerTest.php +++ b/tests/standard/SignerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace bizley\tests; +namespace bizley\tests\standard; use bizley\jwt\Jwt; use Lcobucci\JWT\Signer\Hmac\Sha256; @@ -119,8 +119,8 @@ public static function providerForSigners(): iterable yield 'RS256 with file handler' => [ [ 'signer' => Jwt::RS256, - 'signingKey' => 'file://' . __DIR__ . '/data/rs256.key', - 'verifyingKey' => 'file://' . __DIR__ . '/data/rs256.key.pub', + 'signingKey' => 'file://' . __DIR__ . '/../data/rs256.key', + 'verifyingKey' => 'file://' . __DIR__ . '/../data/rs256.key.pub', ], Jwt::RS256 ]; @@ -128,11 +128,10 @@ public static function providerForSigners(): iterable [ 'signer' => Jwt::RS256, 'signingKey' => [ - Jwt::KEY => 'file://' . __DIR__ . '/data/rs256.key', - Jwt::STORE => Jwt::STORE_IN_MEMORY, + Jwt::KEY => 'file://' . __DIR__ . '/../data/rs256.key', Jwt::METHOD => Jwt::METHOD_FILE, ], - 'verifyingKey' => 'file://' . __DIR__ . '/data/rs256.key.pub', + 'verifyingKey' => 'file://' . __DIR__ . '/../data/rs256.key.pub', ], Jwt::RS256 ]; diff --git a/tests/toolset/BearerTest.php b/tests/toolset/BearerTest.php new file mode 100644 index 0000000..45d655f --- /dev/null +++ b/tests/toolset/BearerTest.php @@ -0,0 +1,314 @@ + 'test', + 'basePath' => __DIR__, + 'vendorPath' => __DIR__ . '/../vendor', + 'components' => [ + 'user' => [ + 'identityClass' => UserIdentity::class, + 'enableSession' => false, + ], + 'request' => [ + 'enableCookieValidation' => false, + 'scriptFile' => __DIR__ . '/index.php', + 'scriptUrl' => '/index.php', + ], + 'jwt' => [ + 'class' => JwtTools::class, + ], + ], + 'controllerMap' => [ + 'test-auth' => TestAuthController::class, + 'test-stub' => TestStubController::class, + 'test-stub2' => TestStub2Controller::class, + ], + ] + ); + } + + protected function getJwt(): JwtTools + { + return Yii::$app->jwt; + } + + public function testEmptyPattern(): void + { + $this->expectException(InvalidConfigException::class); + $controller = Yii::$app->createController('test-stub')[0]; + $controller->run('test'); + } + + public function testHttpBearerAuthNoHeader(): void + { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Your request was made with invalid or expired JSON Web Token.'); + + /* @var $controller Controller */ + $controller = Yii::$app->createController('test-auth')[0]; + $controller->run('filtered'); + } + + public function testHttpBearerAuthInvalidToken(): void + { + $this->expectException(Token\InvalidTokenStructure::class); + $this->expectExceptionMessage('The JWT string must have two dots'); + + Yii::$app->request->headers->set('Authorization', 'Bearer InvalidToken'); + + /* @var $controller Controller */ + $controller = Yii::$app->createController('test-auth')[0]; + $controller->run('filtered'); + } + + public function testHttpBearerAuthInvalidHeader(): void + { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Your request was made with invalid or expired JSON Web Token.'); + + Yii::$app->request->headers->set('Authorization', 'InvalidHeaderValue'); + + /* @var $controller Controller */ + $controller = Yii::$app->createController('test-auth')[0]; + $controller->run('filtered'); + } + + public function testHttpBearerAuthExpiredToken(): void + { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Your request was made with invalid or expired JSON Web Token.'); + + $now = new DateTimeImmutable(); + + $this->getJwt()->validationConstraints = [new LooseValidAt(SystemClock::fromSystemTimezone())]; + + $token = $this->getJwt()->getBuilder() + ->issuedAt($now->modify('-10 minutes')) + ->expiresAt($now->modify('-5 minutes')) + ->getToken( + $this->getJwt()->buildSigner(Jwt::HS256), + $this->getJwt()->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + ) + ->toString(); + + Yii::$app->request->headers->set('Authorization', "Bearer $token"); + + /* @var $controller Controller */ + $controller = Yii::$app->createController('test-auth')[0]; + $controller->run('filtered'); + } + + public function testHttpBearerAuth(): void + { + $now = new DateTimeImmutable(); + + $this->getJwt()->validationConstraints = [ + new LooseValidAt(SystemClock::fromSystemTimezone()), + new IssuedBy('test') + ]; + + $token = $this->getJwt()->getBuilder() + ->issuedAt($now) + ->issuedBy('test') + ->expiresAt($now->modify('+1 hour')) + ->getToken( + $this->getJwt()->buildSigner(Jwt::HS256), + $this->getJwt()->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + ) + ->toString(); + + UserIdentity::$token = $token; + + Yii::$app->request->headers->set('Authorization', "Bearer $token"); + + /** @var Controller $controller */ + $controller = Yii::$app->createController('test-auth')[0]; + + self::assertEquals('test', $controller->run('filtered')); + } + + public function testHttpBearerAuthCustom(): void + { + $now = new DateTimeImmutable(); + + $this->getJwt()->validationConstraints = [new LooseValidAt(SystemClock::fromSystemTimezone())]; + + $token = $this->getJwt()->getBuilder() + ->relatedTo('test') + ->issuedAt($now) + ->expiresAt($now->modify('+1 hour')) + ->getToken( + $this->getJwt()->buildSigner(Jwt::HS256), + $this->getJwt()->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + ); + + $JWT = $token->toString(); + + Yii::$app->request->headers->set('Authorization', "Bearer $JWT"); + + /** @var TestAuthController $controller */ + $controller = Yii::$app->createController('test-auth')[0]; + $controller->filterConfig['auth'] = static function (Token $token) { + $identity = UserIdentity::findIdentity($token->claims()->get('sub')); + Yii::$app->user->switchIdentity($identity); + return $identity; + }; + + self::assertEquals('test', $controller->run('filtered')); + } + + public function testHttpBearerAuthCustomNoIdentity(): void + { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Your request was made with invalid or expired JSON Web Token.'); + + $now = new DateTimeImmutable(); + + $this->getJwt()->validationConstraints = [new LooseValidAt(SystemClock::fromSystemTimezone())]; + + $token = $this->getJwt()->getBuilder() + ->relatedTo('test') + ->issuedAt($now) + ->expiresAt($now->modify('+1 hour')) + ->getToken( + $this->getJwt()->buildSigner(Jwt::HS256), + $this->getJwt()->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + ); + + $JWT = $token->toString(); + + Yii::$app->request->headers->set('Authorization', "Bearer $JWT"); + + /** @var TestAuthController $controller */ + $controller = Yii::$app->createController('test-auth')[0]; + $controller->filterConfig['auth'] = static function (Token $token) { + return null; + }; + $controller->run('filtered'); + } + + public function testHttpBearerAuthCustomNotIdentityInterface(): void + { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Your request was made with invalid or expired JSON Web Token.'); + + $now = new DateTimeImmutable(); + + $this->getJwt()->validationConstraints = [new LooseValidAt(SystemClock::fromSystemTimezone())]; + + $token = $this->getJwt()->getBuilder() + ->relatedTo('test') + ->issuedAt($now) + ->expiresAt($now->modify('+1 hour')) + ->getToken( + $this->getJwt()->buildSigner(Jwt::HS256), + $this->getJwt()->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + ); + + $JWT = $token->toString(); + + Yii::$app->request->headers->set('Authorization', "Bearer $JWT"); + + /** @var TestAuthController $controller */ + $controller = Yii::$app->createController('test-auth')[0]; + $controller->filterConfig['auth'] = static function (Token $token) { + return new stdClass(); + }; + $controller->run('filtered'); + } + + public function testMethodsVisibility(): void + { + $filter = new JwtHttpBearerAuth(['jwt' => $this->getJwt()]); + + $jwt = $filter->getJwtComponent(); + $jwt->validationConstraints = [new IssuedBy('test')]; + self::assertNotEmpty($filter->processToken( + $jwt->getBuilder()->issuedBy('test')->getToken( + $jwt->buildSigner(Jwt::HS256), + $jwt->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + )->toString() + )); + } + + public function testFailVisibility(): void + { + $filter = new TestJwtHttpBearerAuth(['jwt' => $this->getJwt()]); + $filter->fail($this->createMock(Response::class)); + + self::assertSame(2, $filter->flag); + } + + public function testFailedToken(): void + { + $this->expectException(NoConstraintsGiven::class); + + $logger = $this->createMock(Logger::class); + $logger->expects(self::exactly(2))->method('log')->withConsecutive( + ['Route to run: test-stub2/test', 8, 'yii\base\Controller::runAction'], + ['No constraint given.', 2, 'JwtHttpBearerAuth'] + ); + Yii::setLogger($logger); + + $token = $this->getJwt()->getBuilder() + ->getToken( + $this->getJwt()->buildSigner(Jwt::HS256), + $this->getJwt()->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + ) + ->toString(); + + Yii::$app->request->headers->set('Authorization', "Bearer $token"); + + /* @var $controller Controller */ + $controller = Yii::$app->createController('test-stub2')[0]; + $controller->run('test'); + self::assertSame(14, $controller->flag); + } + + public function testSilentException(): void + { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Your request was made with invalid or expired JSON Web Token.'); + // instead of 'The JWT string must have two dots' + + Yii::$app->request->headers->set('Authorization', 'Bearer InvalidToken'); + + /* @var $controller Controller */ + $controller = Yii::$app->createController('test-auth')[0]; + $controller->filterConfig['throwException'] = false; + $controller->run('filtered'); + } +} diff --git a/tests/toolset/ConstraintsConfigTest.php b/tests/toolset/ConstraintsConfigTest.php new file mode 100644 index 0000000..f68d0eb --- /dev/null +++ b/tests/toolset/ConstraintsConfigTest.php @@ -0,0 +1,104 @@ + JwtTools::class, + 'validationConstraints' => $validationConstraints, + ] + ); + } + + private function getToken(JwtTools $jwt): Token + { + return $jwt->getBuilder()->identifiedBy('test')->relatedTo('test')->getToken( + $jwt->buildSigner(Jwt::HS256), + $jwt->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + ); + } + + public function testArrayConfigWithObjects(): void + { + $jwt = $this->getJwt([new IdentifiedBy('test'), new RelatedTo('test')]); + + self::assertTrue($jwt->validate($this->getToken($jwt))); + } + + public function testArrayConfigWithArray(): void + { + $jwt = $this->getJwt([[IdentifiedBy::class, ['test']], [RelatedTo::class, ['test']]]); + + self::assertTrue($jwt->validate($this->getToken($jwt))); + } + + public function testArrayConfigWithYiiArray(): void + { + $jwt = $this->getJwt([['class' => YiiConstraint::class, 'test' => 'yii']]); + + self::assertTrue($jwt->validate($this->getToken($jwt))); + } + + public function testArrayConfigWithClosure(): void + { + $jwt = $this->getJwt(static function (JwtTools $jwt) { + return [new IdentifiedBy('test'), new RelatedTo('test')]; + }); + + self::assertTrue($jwt->validate($this->getToken($jwt))); + } + + public function testDefaultConfig(): void + { + $jwt = $this->getJwt(null); + + $this->expectException(NoConstraintsGiven::class); + $jwt->validate($this->getToken($jwt)); + } + + public function testArrayConfigWithCustomConstraints(): void + { + $constraint1 = $this->createMock(Constraint::class); + $constraint1->expects(self::once())->method('assert'); + $constraint2 = $this->createMock(Constraint::class); + $constraint2->expects(self::once())->method('assert'); + + $jwt = $this->getJwt([$constraint1, $constraint2]); + $jwt->validate($this->getToken($jwt)); + } + + public function testDirectConfigWithCustomConstraints(): void + { + $constraint1 = $this->createMock(Constraint::class); + $constraint1->expects(self::once())->method('assert'); + $constraint2 = $this->createMock(Constraint::class); + $constraint2->expects(self::once())->method('assert'); + + $jwt = $this->getJwt(null); + $jwt->validationConstraints = [$constraint1, $constraint2]; + $jwt->validate($this->getToken($jwt)); + } +} diff --git a/tests/toolset/JwtToolsTest.php b/tests/toolset/JwtToolsTest.php new file mode 100644 index 0000000..3eb7b27 --- /dev/null +++ b/tests/toolset/JwtToolsTest.php @@ -0,0 +1,308 @@ + [Signer\Hmac\Sha256::class], + Jwt::HS384 => [Signer\Hmac\Sha384::class], + Jwt::HS512 => [Signer\Hmac\Sha512::class], + Jwt::RS256 => [Signer\Rsa\Sha256::class], + Jwt::RS384 => [Signer\Rsa\Sha384::class], + Jwt::RS512 => [Signer\Rsa\Sha512::class], + Jwt::ES256 => [Signer\Ecdsa\Sha256::class], + Jwt::ES384 => [Signer\Ecdsa\Sha384::class], + Jwt::ES512 => [Signer\Ecdsa\Sha512::class], + Jwt::EDDSA => [Signer\Eddsa::class], + Jwt::BLAKE2B => [Signer\Blake2b::class], + ], + $this->getJwt()->signers, + ); + } + + public function testValidateSuccess(): void + { + $jwt = $this->getJwt(); + $jwt->validationConstraints = [new IdentifiedBy('abc')]; + $token = $jwt->getBuilder()->identifiedBy('abc')->getToken( + $jwt->buildSigner(Jwt::HS256), + $jwt->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + ); + self::assertTrue($jwt->validate($token)); + } + + public function testValidateSuccessWithStringToken(): void + { + $jwt = $this->getJwt(); + $jwt->validationConstraints = [new IdentifiedBy('abc')]; + $token = $jwt->getBuilder()->identifiedBy('abc')->getToken( + $jwt->buildSigner(Jwt::HS256), + $jwt->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + )->toString(); + self::assertTrue($jwt->validate($token)); + } + + public function testValidateFail(): void + { + $jwt = $this->getJwt(); + $jwt->validationConstraints = [new IdentifiedBy('abc')]; + $token = $jwt->getBuilder()->identifiedBy('def')->getToken( + $jwt->buildSigner(Jwt::HS256), + $jwt->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + ); + self::assertFalse($jwt->validate($token)); + } + + /** + * @doesNotPerformAssertions + */ + public function testAssertSuccess(): void + { + $jwt = $this->getJwt(); + $jwt->validationConstraints = [new IdentifiedBy('abc')]; + $token = $jwt->getBuilder()->identifiedBy('abc')->getToken( + $jwt->buildSigner(Jwt::HS256), + $jwt->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + ); + $jwt->assert($token); + } + + /** + * @doesNotPerformAssertions + */ + public function testAssertSuccessWithStringToken(): void + { + $jwt = $this->getJwt(); + $jwt->validationConstraints = [new IdentifiedBy('abc')]; + $token = $jwt->getBuilder()->identifiedBy('abc')->getToken( + $jwt->buildSigner(Jwt::HS256), + $jwt->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + )->toString(); + $jwt->assert($token); + } + + public function testAssertFail(): void + { + $jwt = $this->getJwt(); + $jwt->validationConstraints = [new IdentifiedBy('abc')]; + $token = $jwt->getBuilder()->identifiedBy('def')->getToken( + $jwt->buildSigner(Jwt::HS256), + $jwt->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + ); + $this->expectException(RequiredConstraintsViolated::class); + $jwt->assert($token); + } + + public static function providerForInvalidKey(): iterable + { + yield 'object' => [new stdClass(), 'Invalid key configuration!']; + yield 'int value' => [[Jwt::KEY => 1], 'Invalid key value!']; + yield 'array value' => [[Jwt::KEY => []], 'Invalid key value!']; + yield 'object value' => [[Jwt::KEY => new stdClass()], 'Invalid key value!']; + yield 'method' => [[Jwt::KEY => 'k', Jwt::METHOD => ''], 'Invalid key method!']; + yield 'int pass' => [[Jwt::KEY => 'k', Jwt::PASSPHRASE => 1], 'Invalid key passphrase!']; + yield 'array pass' => [[Jwt::KEY => 'k', Jwt::PASSPHRASE => []], 'Invalid key passphrase!']; + yield 'object pass' => [[Jwt::KEY => 'k', Jwt::PASSPHRASE => new stdClass()], 'Invalid key passphrase!']; + } + + /** + * @dataProvider providerForInvalidKey + * @param mixed $key + */ + public function testInvalidKey($key, string $message): void + { + $this->expectExceptionMessage($message); + (new JwtTools())->buildKey($key); + } + + public function testCustomEncoder(): void + { + $encoder = $this->createMock(Encoder::class); + $encoder->expects(self::exactly(3))->method('base64UrlEncode'); + + $jwt = new JwtTools(['encoder' => $encoder]); + $jwt->getBuilder()->getToken( + $jwt->buildSigner(Jwt::HS256), + $jwt->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + ); + } + + public function testCustomDecoder(): void + { + $decoder = $this->createMock(Decoder::class); + $decoder->method('jsonDecode')->willReturn([]); + $decoder->expects(self::exactly(3))->method('base64UrlDecode'); + + $jwt = new Jwt( + [ + 'signer' => Jwt::HS256, + 'signingKey' => 'c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M', + 'decoder' => $decoder, + ] + ); + $jwt->parse( + $jwt->getBuilder()->getToken( + $jwt->buildSigner(Jwt::HS256), + $jwt->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + )->toString() + ); + } + + public function testMethodsVisibility(): void + { + $jwt = $this->getJwt(); + self::assertNotEmpty($jwt->getBuilder()); + self::assertNotEmpty($jwt->getParser()); + } + + public static function providerForWrongKeyNames(): iterable + { + yield '@' => ['_@bizley/tests/data/rs256.key']; + yield 'file://' => ['_file://' . __DIR__ . '/data/rs256.key']; + } + + /** + * @dataProvider providerForWrongKeyNames + * @throws InvalidConfigException + */ + public function testWrongFileKeyNameStartingCharacters(string $key): void + { + $jwt = new Jwt( + [ + 'signer' => Jwt::HS256, + 'signingKey' => $key + ] + ); + // name instead of file content + self::assertSame($key, $jwt->getConfiguration()->signingKey()->contents()); + } + + public static function providerForRightKeyNames(): iterable + { + yield '@' => ['@bizley/tests/data/rs256.key']; + yield 'file://' => ['file://' . __DIR__ . '/../data/rs256.key']; + } + + /** + * @dataProvider providerForRightKeyNames + * @throws InvalidConfigException + */ + public function testRightFileKeyNameStartingCharacters(string $key): void + { + $jwt = new Jwt( + [ + 'signer' => Jwt::HS256, + 'signingKey' => $key + ] + ); + // content instead of file name + self::assertSame( + '-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- +', + $jwt->getConfiguration()->signingKey()->contents() + ); + } + + public static function providerForSignerSignatureConverter(): iterable + { + yield Jwt::ES256 => [Jwt::ES256]; + yield Jwt::ES384 => [Jwt::ES384]; + yield Jwt::ES512 => [Jwt::ES512]; + } + + /** + * @dataProvider providerForSignerSignatureConverter + */ + public function testPrepareSignatureConverter(string $signerId): void + { + new Jwt(['signer' => $signerId, 'signingKey' => ' ', 'verifyingKey' => ' ']); + $this->assertInstanceOf( + Signer\Ecdsa\MultibyteStringConverter::class, + Yii::$container->get(Signer\Ecdsa\SignatureConverter::class) + ); + } + + public function testBuilderWithCustomClaimsFormatter(): void + { + $formatter = $this->createMock(ClaimsFormatter::class); + $formatter->expects($this->once())->method('formatClaims'); + $this->getJwt()->getBuilder($formatter)->getToken( + $this->getJwt()->buildSigner(Jwt::HS256), + $this->getJwt()->buildKey('c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0M') + ); + } +} diff --git a/tests/toolset/SignerTest.php b/tests/toolset/SignerTest.php new file mode 100644 index 0000000..39bf8a3 --- /dev/null +++ b/tests/toolset/SignerTest.php @@ -0,0 +1,205 @@ + JwtTools::class], + $config + ) + ); + + return $jwt; + } + + public static function providerForSigners(): iterable + { + yield 'Direct signer provided' => [ + new Sha256(), + 'secret1secret1secret1secret1secret1secret1', + null, + Jwt::HS256 + ]; + yield 'Direct key provided' => [ + Jwt::HS256, + InMemory::plainText('secret1secret1secret1secret1secret1secret1'), + null, + Jwt::HS256 + ]; + yield 'HS256' => [ + Jwt::HS256, + 'secret1secret1secret1secret1secret1secret1', + null, + Jwt::HS256 + ]; + yield 'HS256 base64' => [ + Jwt::HS256, + [ + Jwt::KEY => 'c2VjcmV0MXNlY3JldDFzZWNyZXQxc2VjcmV0MXNlY3JldDFzZWNyZXQx', + Jwt::METHOD => JWT::METHOD_BASE64 + ], + null, + Jwt::HS256 + ]; + yield 'HS384' => [ + Jwt::HS384, + 'secret1secret1secret1secret1secret1secret1secret1', + null, + Jwt::HS384 + ]; + yield 'HS512' => [ + Jwt::HS512, + 'secret1secret1secret1secret1secret1secret1secret1secret1secret1secret1', + null, + Jwt::HS512 + ]; + yield 'HS256 pass' => [ + Jwt::HS256, + [ + Jwt::KEY => 'secret1secret1secret1secret1secret1secret1secret1', + Jwt::PASSPHRASE => 'passphrase' + ], + null, + Jwt::HS256 + ]; + yield 'HS384 pass' => [ + Jwt::HS384, + [ + Jwt::KEY => 'secret1secret1secret1secret1secret1secret1secret1', + Jwt::PASSPHRASE => 'passphrase' + ], + null, + Jwt::HS384 + ]; + yield 'HS512 pass' => [ + Jwt::HS512, + [ + Jwt::KEY => 'secret1secret1secret1secret1secret1secret1secret1secret1secret1secret1', + Jwt::PASSPHRASE => 'passphrase' + ], + null, + Jwt::HS512 + ]; + yield 'RS256' => [ + Jwt::RS256, + '@bizley/tests/data/rs256.key', + '@bizley/tests/data/rs256.key.pub', + Jwt::RS256 + ]; + yield 'RS256 with file handler' => [ + Jwt::RS256, + 'file://' . __DIR__ . '/../data/rs256.key', + 'file://' . __DIR__ . '/../data/rs256.key.pub', + Jwt::RS256 + ]; + yield 'RS256 with in-memory file' => [ + Jwt::RS256, + [ + Jwt::KEY => 'file://' . __DIR__ . '/../data/rs256.key', + Jwt::METHOD => Jwt::METHOD_FILE, + ], + 'file://' . __DIR__ . '/../data/rs256.key.pub', + Jwt::RS256 + ]; + yield 'RS384' => [ + Jwt::RS384, + '@bizley/tests/data/rs384.key', + '@bizley/tests/data/rs384.key.pub', + Jwt::RS384 + ]; + yield 'RS512' => [ + Jwt::RS512, + '@bizley/tests/data/rs512.key', + '@bizley/tests/data/rs512.key.pub', + Jwt::RS512 + ]; + yield 'ES256' => [ + Jwt::ES256, + '@bizley/tests/data/es256.key', + '@bizley/tests/data/es256.key.pub', + Jwt::ES256 + ]; + yield 'ES384' => [ + Jwt::ES384, + '@bizley/tests/data/es384.key', + '@bizley/tests/data/es384.key.pub', + Jwt::ES384 + ]; + yield 'ES512' => [ + Jwt::ES512, + '@bizley/tests/data/es512.key', + '@bizley/tests/data/es512.key.pub', + Jwt::ES512 + ]; + } + + /** + * @dataProvider providerForSigners + * @throws InvalidConfigException + */ + public function testParseTokenWithSignature( + string|object $signer, + string|array|object $signingKey, + string|array|null $verifyingKey, + string $algorithm + ): void { + $jwt = $this->getJwt(); + $builtSigner = $jwt->buildSigner($signer); + $builtSigningKey = $jwt->buildKey($signingKey); + $token = $jwt->getBuilder()->getToken($builtSigner, $builtSigningKey); + $tokenParsed = $jwt->parse($token->toString()); + self::assertSame('JWT', $tokenParsed->headers()->get('typ')); + self::assertSame($algorithm, $tokenParsed->headers()->get('alg')); + + self::assertTrue( + $jwt->getValidator()->validate( + $tokenParsed, + new SignedWith($builtSigner, $verifyingKey ? $jwt->buildKey($verifyingKey) : $builtSigningKey) + ) + ); + } + + public function testInvalidSignerId(): void + { + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Invalid signer ID!'); + $this->getJwt()->buildSigner('Invalid'); + } + + public function testEmptyKey(): void + { + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Empty string used as a key configuration!'); + $this->getJwt()->buildKey(''); + } + + public function testInvalidKeyWithNotEnoughBits(): void + { + $this->expectException(InvalidKeyProvided::class); + $this->expectExceptionMessage('Key provided is shorter than 256 bits, only 56 bits provided'); + $jwt = $this->getJwt(); + $signer = $jwt->buildSigner(Jwt::HS256); + $token = $jwt->getBuilder()->getToken($signer, $jwt->buildKey('secret1')); + $jwt->parse($token->toString()); + } +}