Skip to content

Commit

Permalink
JWK creation and validation improvements.
Browse files Browse the repository at this point in the history
LittleJWTBuilder allows JWKValidator to be specified.
The default JWKValidator can be specified.
LittleJWT is created with JWK validator.
A random JWK is generated if JWK validation fails (by default).
The JWK is passed when JWKValidator is invoked, not constructed.
A fallback for when JWK validation fails can be specified.
JWKValidator uses AlgorithmBuilder to validate JWK algorithm.
  • Loading branch information
little-apps committed Jun 1, 2024
1 parent e5ad580 commit 7c6be5c
Show file tree
Hide file tree
Showing 3 changed files with 169 additions and 64 deletions.
57 changes: 47 additions & 10 deletions src/Factories/LittleJWTBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,62 @@

namespace LittleApps\LittleJWT\Factories;

use Illuminate\Contracts\Foundation\Application;
use LittleApps\LittleJWT\JWK\JsonWebKey;
use LittleApps\LittleJWT\LittleJWT;
use LittleApps\LittleJWT\JWK\JWKValidator;

class LittleJWTBuilder
{
/**
* Creates LittleJWT instance.
* JWKValidator to use
*
* @param Application $app Application container
* @param JsonWebKey $jwk JWK to sign and verify JWTs with.
* @param boolean $validateJwk If true, validates JWK (default: true)
* @return static
* @var JWKValidator|null
*/
public static function create(Application $app, JsonWebKey $jwk, bool $validateJwk = true): LittleJWT {
if ($validateJwk)
JWKValidator::validate($jwk);
protected ?JWKValidator $jwkValidator;

return new LittleJWT($app, $jwk);
/**
* Initializes LittleJWTBuilder instance
*
* @param JsonWebKey $jwk JWK to use with LittleJWT instance.
*/
public function __construct(
protected readonly JsonWebKey $jwk
)
{

}

/**
* Specifies JWKValidator to use before building LittleJWT.
*
* @param JWKValidator $jwkValidator
* @return $this
*/
public function withJwkValidator(JWKValidator $jwkValidator) {
$this->jwkValidator = $jwkValidator;

return $this;
}

/**
* Specifies to not use JWKValidator before building LittleJWT.
*
* @return $this
*/
public function withoutJwkValidator() {
$this->jwkValidator = null;

return $this;
}

/**
* Builds LittleJWT instance
*
* @return LittleJWT
*/
public function build(): LittleJWT {
$jwk = isset($this->jwkValidator) ? $this->jwkValidator->__invoke($this->jwk) : $this->jwk;

return new LittleJWT(app(), $jwk);
}
}
159 changes: 106 additions & 53 deletions src/JWK/JWKValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,60 +2,86 @@

namespace LittleApps\LittleJWT\JWK;

use Jose\Component\Core\Algorithm;
use Jose\Component\Core\JWK;
use Jose\Component\Signature\Algorithm as JoseAlgorithms;
use Closure;
use Jose\Component\Signature\Algorithm\ECDSA;
use Jose\Component\Signature\Algorithm\HMAC;
use Jose\Component\Signature\Algorithm\RSAPKCS1;
use Jose\Component\Signature\Algorithm\RSAPSS;
use LittleApps\LittleJWT\Exceptions\HashAlgorithmNotFoundException;
use LittleApps\LittleJWT\Exceptions\InvalidHashAlgorithmException;
use LittleApps\LittleJWT\Exceptions\InvalidJWKException;
use LittleApps\LittleJWT\Factories\AlgorithmBuilder;

class JWKValidator
{
protected $algorithms = [
'HS256' => JoseAlgorithms\HS256::class,
'HS384' => JoseAlgorithms\HS384::class,
'HS512' => JoseAlgorithms\HS512::class,
/**
* Gets default callback for JWKValidator
*
* @var ?Closure
*/
protected static ?Closure $defaults;

'ES256' => JoseAlgorithms\ES256::class,
'ES384' => JoseAlgorithms\ES384::class,
'ES512' => JoseAlgorithms\ES512::class,
/**
* Fallback for when validation fails
*
* @var ?Closure(): static
*/
protected ?Closure $fallback = null;

'RS256' => JoseAlgorithms\RS256::class,
'RS384' => JoseAlgorithms\RS384::class,
'RS512' => JoseAlgorithms\RS512::class,
/**
* Initializes JWKValidator instance
*
*/
public function __construct()
{
}

'PS256' => JoseAlgorithms\PS256::class,
'PS384' => JoseAlgorithms\PS384::class,
'PS512' => JoseAlgorithms\PS512::class,
/**
* Sets fallback
*
* @param callable(): static $fallback
* @return void
*/
public function withFallback(callable $fallback) {
$this->fallback = $fallback;

'EDDSA' => JoseAlgorithms\EdDSA::class,
return $this;
}

'NONE' => JoseAlgorithms\None::class,
];
public function withoutFallback() {
$this->fallback = null;

return $this;
}

/**
* Initializes JWKValidator instance
* Validates the JSON Web Key
*
* @param JsonWebKey $jsonWebKey
* @return JsonWebKey
* @throws InvalidJWKException Thrown if JWK is invalid and fallback is not set.
*/
public function __construct(
protected readonly JsonWebKey $jsonWebKey
)
public function __invoke(JsonWebKey $jsonWebKey)
{
try {
$this->perform($jsonWebKey);

return $jsonWebKey;
} catch (InvalidJWKException $ex) {
if (isset($this->fallback))
return call_user_func($this->fallback);
else
throw $ex;
}
}

/**
* Validates the JSON Web Key
* Performs the validation
*
* @return void
* @throws InvalidJWKException Thrown if JWK is invalid.
*/
public function __invoke() {
$values = $this->jsonWebKey->all();
protected function perform(JsonWebKey $jsonWebKey) {
$values = $jsonWebKey->all();

if (!isset($values['alg'])) {
throw new InvalidJWKException("The 'alg' value is missing.");
Expand All @@ -65,48 +91,67 @@ public function __invoke() {
throw new InvalidJWKException("The 'kty' value is missing.");
}

$alg = strtoupper($values['alg']);

$algorithm = $this->jsonWebKey->algorithm();
$this->validateAlgorithm($jsonWebKey);

if (!$this->isAlgorithmSupported($algorithm)) {
throw new InvalidJWKException("JSON Web Key algorithm '{$alg}' is not supported.");
}
$algorithm = $this->getAlgorithm($jsonWebKey);

if ($algorithm instanceof HMAC)
$this->validateHmacKey();
$this->validateHmacKey($jsonWebKey);
else if ($algorithm instanceof ECDSA)
$this->validateEcdsaKey();
$this->validateEcdsaKey($jsonWebKey);
else if ($algorithm instanceof RSAPSS || $algorithm instanceof RSAPKCS1)
$this->validateRsaKey();
$this->validateRsaKey($jsonWebKey);
}

/**
* Checks if algorithm is supported.
*
* @param Algorithm $algorithm
* @return boolean
* @param JsonWebKey $jsonWebKey
* @return void
*/
protected function isAlgorithmSupported(Algorithm $algorithm): bool {
$algorithms = array_flip($this->algorithms);
protected function validateAlgorithm(JsonWebKey $jsonWebKey) {
$alg = $jsonWebKey->get('alg');

return isset($algorithms[get_class($algorithm)]);
$class = AlgorithmBuilder::getAlgorithmClass($alg);

if (is_null($class))
throw new InvalidJWKException("JSON Web Key algorithm '{$alg}' is not supported.");

try {
$jsonWebKey->algorithm();
} catch (InvalidHashAlgorithmException | HashAlgorithmNotFoundException $ex) {
throw new InvalidJWKException($ex->getMessage());
}
}

/**
* Gets algorithm instance from JWK
*
* @param JsonWebKey $jsonWebKey
* @return \Jose\Component\Core\Algorithm
*/
protected function getAlgorithm(JsonWebKey $jsonWebKey) {
try {
return $jsonWebKey->algorithm();
} catch (InvalidHashAlgorithmException | HashAlgorithmNotFoundException $ex) {
throw new InvalidJWKException($ex->getMessage());
}
}

/**
* Validates an HMAC key.
*
* @return void
*/
protected function validateHmacKey() {
protected function validateHmacKey(JsonWebKey $jsonWebKey) {
static $minKeyLengths = [
'HS256' => 32,
'HS384' => 48,
'HS512' => 64,
];

$alg = strtoupper($this->jsonWebKey->get('alg'));
$key = $this->jsonWebKey->get('k') ?? '';
$alg = strtoupper($jsonWebKey->get('alg'));
$key = $jsonWebKey->get('k') ?? '';

if (mb_strlen($key, '8bit') < $minKeyLengths[$alg])
throw new InvalidJWKException("The key is not long enough.");
Expand All @@ -117,11 +162,11 @@ protected function validateHmacKey() {
*
* @return void
*/
protected function validateEcdsaKey() {
protected function validateEcdsaKey(JsonWebKey $jsonWebKey) {
$required = ['x', 'y', 'crv'];

foreach ($required as $key) {
if (!$this->jsonWebKey->has($key)) {
if (!$jsonWebKey->has($key)) {
throw new InvalidJWKException("The '{$key}' key is required in ECDSA JSON Web Keys.");
}
}
Expand All @@ -132,24 +177,32 @@ protected function validateEcdsaKey() {
*
* @return void
*/
protected function validateRsaKey() {
protected function validateRsaKey(JsonWebKey $jsonWebKey) {
$required = ['n', 'e'];

foreach ($required as $key) {
if (!$this->jsonWebKey->has($key)) {
if (!$jsonWebKey->has($key)) {
throw new InvalidJWKException("The '{$key}' key is required in RSA JSON Web Keys.");
}
}
}

/**
* Runs JWK validation
* Specifies callback for creating default JWKValidator
*
* @param JsonWebKey $jsonWebKey
* @param callable $callback
* @return void
* @throws InvalidJWKException
*/
public static function validate(JsonWebKey $jsonWebKey) {
(new self($jsonWebKey))->__invoke();
public static function defaults(callable $callback): void {
static::$defaults = $callback;
}

/**
* Creates default JWKValidator instance.
*
* @return static
*/
public static function default(): static {
return isset(static::$defaults) ? call_user_func(static::$defaults) : new self;
}
}
17 changes: 16 additions & 1 deletion src/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public function packageBooted()
$this->bootGuard();
$this->bootMacros();
$this->bootValidatorRules();
$this->bootJwkValidator();
}

/**
Expand All @@ -86,7 +87,9 @@ public function packageBooted()
protected function registerCore()
{
$this->app->singleton(LittleJWT::class, function (Container $app) {
return LittleJWTBuilder::create($app, $app->make(JsonWebKey::class));
$builder = new LittleJWTBuilder($app->make(JsonWebKey::class));

return $builder->withJwkValidator(JWKValidator::default())->build();
});

$this->app->bind(JsonWebKey::class, function (Container $app) {
Expand Down Expand Up @@ -316,4 +319,16 @@ protected function bootMacros()
JsonResponse::macro('attachJwt', $attachJwtCallback);
RedirectResponse::macro('attachJwt', $attachJwtCallback);
}

/**
* Boots JWKValidator
*
* @return void
*/
protected function bootJwkValidator() {
JWKValidator::defaults(fn () => (new JWKValidator())->withFallback(fn () => KeyBuilder::generateRandomJwk(1024, [
'alg' => 'HS256',
'use' => 'sig'
])));
}
}

0 comments on commit 7c6be5c

Please sign in to comment.