Revising how/what exceptions are thrown (unfinished).

This commit is contained in:
flash 2025-05-15 22:19:59 +02:00
parent 46ec5b9d88
commit dc38d6e423
Signed by: flash
GPG key ID: 6D833BE0D210AC02
21 changed files with 319 additions and 87 deletions

View file

@ -1 +1 @@
v0.3.3
v0.4.0

View file

@ -2,7 +2,6 @@
namespace Railgun\Jwt;
use InvalidArgumentException;
use RuntimeException;
/**
* Represents a JSON Web Key Set backed by an array.
@ -55,21 +54,21 @@ class ArrayJwkSet implements JwkSet {
foreach($this->keys as $key) {
$keyAlgo = $key->getAlgorithm();
if($keyAlgo !== null && hash_equals($keyAlgo, $algo))
if($keyAlgo !== null && $keyAlgo === $algo)
return $key;
}
throw new RuntimeException('could not find a key that matched the requested algorithm');
throw new JwkNotFoundException('could not find a key that matched the requested algorithm');
}
if(!array_key_exists($keyId, $this->keys))
throw new RuntimeException('could not find a key with that id');
throw new JwkNotFoundException('could not find a key with that id');
$key = $this->keys[$keyId];
$keyAlgo = $key->getAlgorithm();
if($algo !== null && $keyAlgo !== null && !hash_equals($keyAlgo, $algo))
throw new RuntimeException('requested algorithm does not match');
if($algo !== null && $keyAlgo !== null && $keyAlgo !== $algo)
throw new JwkAlgorithmMismatchException('requested algorithm does not match');
return $key;
}

37
src/ErrorWithPayload.php Normal file
View file

@ -0,0 +1,37 @@
<?php
namespace Railgun\Jwt;
use Error;
use Throwable;
/**
* Provides an Error with access to a JWT's payload.
*/
class ErrorWithPayload extends Error implements ThrowableWithPayload {
/** @var object */
private $payload;
/**
* @param object $payload Data that was provided for the payload section.
* @param string $message The Exception message to throw.
* @param int $code The Exception code.
* @param ?Throwable $previous The previous exception used for the exception chaining.
*/
public function __construct(
object $payload,
string $message = '',
int $code = 0,
?Throwable $previous = null
) {
$this->payload = $payload;
parent::__construct($message, $code, $previous);
}
public function getPayload(): object {
return $this->payload;
}
public function withPayload(object $payload) {
return new ErrorWithPayload($payload, $this->getMessage(), $this->getCode(), $this);
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace Railgun\Jwt;
use Exception;
use Throwable;
/**
* Provides an Exception with access to a JWT's payload.
*/
class ExceptionWithPayload extends Exception implements ThrowableWithPayload {
/** @var object */
private $payload;
/**
* @param object $payload Data that was provided for the payload section.
* @param string $message The Exception message to throw.
* @param int $code The Exception code.
* @param ?Throwable $previous The previous exception used for the exception chaining.
*/
public function __construct(
object $payload,
string $message = '',
int $code = 0,
?Throwable $previous = null
) {
$this->payload = $payload;
parent::__construct($message, $code, $previous);
}
public function getPayload(): object {
return $this->payload;
}
public function withPayload(object $payload) {
return new ExceptionWithPayload($payload, $this->getMessage(), $this->getCode(), $this);
}
}

View file

@ -2,7 +2,7 @@
namespace Railgun\Jwt;
use InvalidArgumentException;
use RuntimeException;
use UnexpectedValueException;
use Railgun\Jwt\Utility\UriBase64;
/**
@ -65,7 +65,7 @@ class HmacJwk implements Jwk {
$result = hash_hmac(self::$algos[$algo], $data, $this->key, true);
if($result === false) // @phpstan-ignore identical.alwaysFalse
throw new RuntimeException('failed to sign $data');
throw new UnexpectedValueException('failed to sign $data');
return $result;
}

View file

@ -7,7 +7,7 @@ use Throwable;
/**
* Provides an InvalidArgumentException with access to a JWT's payload.
*/
class InvalidArgumentExceptionWithPayload extends InvalidArgumentException implements WithPayload {
class InvalidArgumentExceptionWithPayload extends InvalidArgumentException implements ThrowableWithPayload {
/** @var object */
private $payload;
@ -31,14 +31,7 @@ class InvalidArgumentExceptionWithPayload extends InvalidArgumentException imple
return $this->payload;
}
/**
* Converts a normal InvalidArgumentException to one with a payload.
*
* @param object $payload Data that was provided for the payload section.
* @param InvalidArgumentException $previous Exception to convert.
* @return InvalidArgumentExceptionWithPayload
*/
public static function convert(object $payload, InvalidArgumentException $previous) {
return new InvalidArgumentExceptionWithPayload($payload, $previous->getMessage(), $previous->getCode(), $previous);
public function withPayload(object $payload) {
return new InvalidArgumentExceptionWithPayload($payload, $this->getMessage(), $this->getCode(), $this);
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace Railgun\Jwt;
/**
* Thrown when JwkSet::getKey() can find an algorithm with the provided key id, but the algorithm is a mismatch.
*/
class JwkAlgorithmMismatchException extends JwkNotFoundException implements ThrowableWithPayloadEquivalent {
public function withPayload(object $payload) {
return new JwkAlgorithmMismatchExceptionWithPayload($payload, $this->getMessage(), $this->getCode(), $this);
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace Railgun\Jwt;
use Throwable;
/**
* Thrown when JwkSet::getKey() can find an algorithm with the provided key id, but the algorithm is a mismatch.
*/
class JwkAlgorithmMismatchExceptionWithPayload extends JwkAlgorithmMismatchException implements ThrowableWithPayload {
/** @var object */
private $payload;
/**
* @param object $payload Data that was provided for the payload section.
* @param string $message The Exception message to throw.
* @param int $code The Exception code.
* @param ?Throwable $previous The previous exception used for the exception chaining.
*/
public function __construct(
object $payload,
string $message = '',
int $code = 0,
?Throwable $previous = null
) {
$this->payload = $payload;
parent::__construct($message, $code, $previous);
}
public function getPayload(): object {
return $this->payload;
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace Railgun\Jwt;
use RuntimeException;
/**
* Thrown when JwkSet::getKey() cannot find a key that matches the provided arguments.
*/
class JwkNotFoundException extends RuntimeException implements ThrowableWithPayloadEquivalent {
public function withPayload(object $payload) {
return new JwkNotFoundExceptionWithPayload($payload, $this->getMessage(), $this->getCode(), $this);
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace Railgun\Jwt;
use Throwable;
/**
* Thrown when JwkSet::getKey() cannot find a key that matches the provided arguments.
*/
class JwkNotFoundExceptionWithPayload extends JwkNotFoundException implements ThrowableWithPayload {
/** @var object */
private $payload;
/**
* @param object $payload Data that was provided for the payload section.
* @param string $message The Exception message to throw.
* @param int $code The Exception code.
* @param ?Throwable $previous The previous exception used for the exception chaining.
*/
public function __construct(
object $payload,
string $message = '',
int $code = 0,
?Throwable $previous = null
) {
$this->payload = $payload;
parent::__construct($message, $code, $previous);
}
public function getPayload(): object {
return $this->payload;
}
}

View file

@ -4,7 +4,8 @@ namespace Railgun\Jwt;
use stdClass;
use InvalidArgumentException;
use RuntimeException;
use Railgun\Jwt\Utility\{UriBase64,Json};
use Throwable;
use Railgun\Jwt\Utility\{ExceptionUtils,UriBase64,Json};
/**
* Implements the JWT decoder and encoder.
@ -70,39 +71,44 @@ class JwtEncoder {
if(!is_object($payload))
throw new InvalidArgumentException('$token payload part must be a JSON object');
// decode headers section and ensure its type
$headersRaw = UriBase64::decode($headersEnc);
$headersDec = Json::decode($headersRaw);
if(!is_object($headersDec))
throw new InvalidArgumentExceptionWithPayload($payload, '$token header part must be a JSON object');
try {
// decode headers section and ensure its type
$headersRaw = UriBase64::decode($headersEnc);
$headersDec = Json::decode($headersRaw);
if(!is_object($headersDec))
throw new InvalidArgumentExceptionWithPayload($payload, '$token header part must be a JSON object');
if($headers === null) // assign to $headers if null
$headers = $headersDec;
elseif($mergeHeaders) // merge if needed
$headers = (object)array_merge((array)$headersDec, $headers);
else // fix type of $headers
$headers = (object)$headers;
if($headers === null) // assign to $headers if null
$headers = $headersDec;
elseif($mergeHeaders) // merge if needed
$headers = (object)array_merge((array)$headersDec, $headers);
else // fix type of $headers
$headers = (object)$headers;
// verify alg and kid header claims
if(!property_exists($headers, 'alg') || !is_string($headers->alg))
throw new InvalidArgumentExceptionWithPayload($payload, '$token does not contain a valid alg claim in its headers section');
// verify alg and kid header claims
if(!property_exists($headers, 'alg') || !is_string($headers->alg))
throw new InvalidArgumentExceptionWithPayload($payload, '$token does not contain a valid alg claim in its headers section');
// attempt to select the specified key
$alg = $headers->alg;
$kid = property_exists($headers, 'kid') && is_string($headers->kid) ? $headers->kid : null;
$jwk = $this->keys->getKey($kid, $alg);
// attempt to select the specified key
$alg = $headers->alg;
$kid = property_exists($headers, 'kid') && is_string($headers->kid) ? $headers->kid : null;
$jwk = $this->keys->getKey($kid, $alg);
// decode signature and reconstruct the value input
$sig = UriBase64::decode($sigEnc);
$enc = sprintf('%s.%s', $headersEnc, $payloadEnc);
// decode signature and reconstruct the value input
$sig = UriBase64::decode($sigEnc);
$enc = sprintf('%s.%s', $headersEnc, $payloadEnc);
// verify the signature section of $token
if(!$jwk->verify($enc, $sig, $alg))
throw new InvalidArgumentExceptionWithPayload($payload, '$token could not be verified');
// verify the signature section of $token
if(!$jwk->verify($enc, $sig, $alg))
throw new InvalidArgumentExceptionWithPayload($payload, '$token could not be verified');
// Run validators over the headers and payload sections
foreach($this->validators as $validator)
$validator->validate($headers, $payload);
// Run validators over the headers and payload sections
foreach($this->validators as $validator)
$validator->validate($headers, $payload);
} catch(Throwable $ex) {
// bundle our payload
throw ExceptionUtils::ensureWithPayload($payload, $ex);
}
// enjoy your payload!
return $payload;

View file

@ -0,0 +1,37 @@
<?php
namespace Railgun\Jwt;
use LogicException;
use Throwable;
/**
* Provides a LogicException with access to a JWT's payload.
*/
class LogicExceptionWithPayload extends LogicException implements ThrowableWithPayload {
/** @var object */
private $payload;
/**
* @param object $payload Data that was provided for the payload section.
* @param string $message The Exception message to throw.
* @param int $code The Exception code.
* @param ?Throwable $previous The previous exception used for the exception chaining.
*/
public function __construct(
object $payload,
string $message = '',
int $code = 0,
?Throwable $previous = null
) {
$this->payload = $payload;
parent::__construct($message, $code, $previous);
}
public function getPayload(): object {
return $this->payload;
}
public function withPayload(object $payload) {
return new LogicExceptionWithPayload($payload, $this->getMessage(), $this->getCode(), $this);
}
}

View file

@ -1,8 +1,6 @@
<?php
namespace Railgun\Jwt;
use RuntimeException;
/**
* Represents a JwkSet containing only a NoneJwk.
*/
@ -24,7 +22,7 @@ class NoneJwkSet implements JwkSet {
public function getKey(?string $keyId = null, ?string $algo = null): Jwk {
if($algo !== null && $algo !== 'none')
throw new RuntimeException('could not find a key that matched the requested algorithm');
throw new JwkAlgorithmMismatchException('could not find a key that matched the requested algorithm');
return $this->jwk;
}

View file

@ -7,7 +7,7 @@ use Throwable;
/**
* Provides an RuntimeException with access to a JWT's payload.
*/
class RuntimeExceptionWithPayload extends RuntimeException implements WithPayload {
class RuntimeExceptionWithPayload extends RuntimeException implements ThrowableWithPayload {
/** @var object */
private $payload;
@ -31,14 +31,7 @@ class RuntimeExceptionWithPayload extends RuntimeException implements WithPayloa
return $this->payload;
}
/**
* Converts a normal RuntimeException to one with a payload.
*
* @param object $payload Data that was provided for the payload section.
* @param Throwable $previous Exception to convert.
* @return RuntimeExceptionWithPayload
*/
public static function convert(object $payload, Throwable $previous) {
return new RuntimeExceptionWithPayload($payload, $previous->getMessage(), (int)$previous->getCode(), $previous);
public function withPayload(object $payload) {
return new RuntimeExceptionWithPayload($payload, $this->getMessage(), $this->getCode(), $this);
}
}

View file

@ -2,9 +2,9 @@
namespace Railgun\Jwt;
/**
* Provides a common interface to check for exception types that contain ::getPayload().
* Provides a common interface to check for Throwable types that contain ::getPayload().
*/
interface WithPayload {
interface ThrowableWithPayload extends ThrowableWithPayloadEquivalent {
/**
* Gets the JWT payload value.
*

View file

@ -0,0 +1,17 @@
<?php
namespace Railgun\Jwt;
use Throwable;
/**
* Complements the WithPayload interface.
*/
interface ThrowableWithPayloadEquivalent extends Throwable {
/**
* Creates a clone of the current object using its equivalent that can contain JWT payload data.
*
* @param object $payload JWT payload data.
* @return ThrowableWithPayload
*/
public function withPayload(object $payload);
}

View file

@ -7,7 +7,7 @@ use UnexpectedValueException;
/**
* Provides an UnexpectedValueException with access to a JWT's payload.
*/
class UnexpectedValueExceptionWithPayload extends UnexpectedValueException implements WithPayload {
class UnexpectedValueExceptionWithPayload extends UnexpectedValueException implements ThrowableWithPayload {
/** @var object */
private $payload;
@ -31,14 +31,7 @@ class UnexpectedValueExceptionWithPayload extends UnexpectedValueException imple
return $this->payload;
}
/**
* Converts a normal UnexpectedValueException to one with a payload.
*
* @param object $payload Data that was provided for the payload section.
* @param UnexpectedValueException $previous Exception to convert.
* @return UnexpectedValueExceptionWithPayload
*/
public static function convert(object $payload, UnexpectedValueException $previous): self {
return new UnexpectedValueExceptionWithPayload($payload, $previous->getMessage(), $previous->getCode(), $previous);
public function withPayload(object $payload) {
return new UnexpectedValueExceptionWithPayload($payload, $this->getMessage(), $this->getCode(), $this);
}
}

View file

@ -1,14 +1,21 @@
<?php
namespace Railgun\Jwt\Utility;
use Error;
use InvalidArgumentException;
use LogicException;
use RuntimeException;
use Throwable;
use UnexpectedValueException;
use Railgun\Jwt\{
ErrorWithPayload,
ExceptionWithPayload,
InvalidArgumentExceptionWithPayload,
LogicExceptionWithPayload,
RuntimeExceptionWithPayload,
ThrowableWithPayload,
ThrowableWithPayloadEquivalent,
UnexpectedValueExceptionWithPayload,
WithPayload
};
/**
@ -22,19 +29,33 @@ final class ExceptionUtils {
*
* @param object $payload JWT payload section.
* @param Throwable $exception Exception to potentially convert.
* @return Throwable&WithPayload
* @return ThrowableWithPayload
*/
public static function ensureWithPayload(object $payload, Throwable $exception): Throwable {
public static function ensureWithPayload(object $payload, Throwable $exception): ThrowableWithPayload {
// If we already have a payload, just return immediately.
if($exception instanceof WithPayload && $exception->getPayload() === $payload)
if($exception instanceof ThrowableWithPayload && $exception->getPayload() === $payload)
return $exception;
if($exception instanceof InvalidArgumentException)
return InvalidArgumentExceptionWithPayload::convert($payload, $exception);
if($exception instanceof ThrowableWithPayloadEquivalent)
return $exception->withPayload($payload);
if($exception instanceof UnexpectedValueException)
return UnexpectedValueExceptionWithPayload::convert($payload, $exception);
if($exception instanceof LogicException) {
if($exception instanceof InvalidArgumentException)
return new InvalidArgumentExceptionWithPayload($payload, $exception->getMessage(), $exception->getCode(), $exception);
return RuntimeExceptionWithPayload::convert($payload, $exception);
return new LogicExceptionWithPayload($payload, $exception->getMessage(), $exception->getCode(), $exception);
}
if($exception instanceof RuntimeException) {
if($exception instanceof UnexpectedValueException)
return new UnexpectedValueExceptionWithPayload($payload, $exception->getMessage(), $exception->getCode(), $exception);
return new RuntimeExceptionWithPayload($payload, $exception->getMessage(), (int)$exception->getCode(), $exception);
}
if($exception instanceof Error)
return new ErrorWithPayload($payload, $exception->getMessage(), $exception->getCode(), $exception);
return new ExceptionWithPayload($payload, $exception->getMessage(), (int)$exception->getCode(), $exception);
}
}

View file

@ -2,7 +2,10 @@
use PHPUnit\Framework\TestCase;
use Railgun\Jwt\{
ErrorWithPayload,
ExceptionWithPayload,
InvalidArgumentExceptionWithPayload,
LogicExceptionWithPayload,
RuntimeExceptionWithPayload,
UnexpectedValueExceptionWithPayload,
WithPayload
@ -24,12 +27,16 @@ final class ExceptionUtilsTest extends TestCase {
[new InvalidArgumentException('test', 1234), InvalidArgumentExceptionWithPayload::class, $payload, false],
[new UnexpectedValueException('test', 5678), UnexpectedValueExceptionWithPayload::class, $payload, false],
[new RuntimeException('test', 0xF00F), RuntimeExceptionWithPayload::class, $payload, false],
[new LogicException('test', 0, new InvalidArgumentException('inner')), RuntimeExceptionWithPayload::class, $payload, false],
[new Exception('test'), RuntimeExceptionWithPayload::class, $payload, false],
[new LogicException('test', 0, new InvalidArgumentException('inner')), LogicExceptionWithPayload::class, $payload, false],
[new Exception('test'), ExceptionWithPayload::class, $payload, false],
[new InvalidArgumentExceptionWithPayload($payload, 'test'), InvalidArgumentExceptionWithPayload::class, $payload, true],
[new UnexpectedValueExceptionWithPayload($payload, 'test'), UnexpectedValueExceptionWithPayload::class, $payload, true],
[new RuntimeExceptionWithPayload($payload, 'test'), RuntimeExceptionWithPayload::class, $payload, true],
[new RuntimeExceptionWithPayload($other, 'test'), RuntimeExceptionWithPayload::class, $payload, false],
[new TypeError('test'), ErrorWithPayload::class, $payload, false],
[new LengthException('test'), LogicExceptionWithPayload::class, $payload, false],
[new IntlException('test'), ExceptionWithPayload::class, $payload, false],
[new PDOException('test'), RuntimeExceptionWithPayload::class, $payload, false],
];
}

View file

@ -9,7 +9,7 @@ use Railgun\Jwt\Validators\{
final class NotValidBeforeValidatorTest extends TestCase {
/**
* @return array{int, int, string[]|null, object, ?class-string<Throwable>}[]
* @return array{int, int, ?non-empty-array<string>, object, ?class-string<Throwable>}[]
*/
public function notValidBeforeDataProvider(): array {
return [
@ -27,6 +27,8 @@ final class NotValidBeforeValidatorTest extends TestCase {
}
/**
* @param ?non-empty-array<string> $claims
* @param ?class-string<Throwable> $throwable
* @dataProvider notValidBeforeDataProvider
*/
public function testNotValidBefore(int $leeway, int $time, ?array $claims, object $payload, ?string $throwable): void {
@ -43,6 +45,7 @@ final class NotValidBeforeValidatorTest extends TestCase {
? new NotValidBeforeValidator($leeway, $time)
: new NotValidBeforeValidator($leeway, $time, $claims);
$this->assertNull($validator->validate((object)[], $payload));
$validator->validate((object)[], $payload);
$this->addToAssertionCount(1);
}
}

View file

@ -9,7 +9,7 @@ use Railgun\Jwt\Validators\{
final class TokenExpirationValidatorTest extends TestCase {
/**
* @return array{int, int, string[]|null, object, ?class-string<Throwable>}[]
* @return array{int, int, ?non-empty-array<string>, object, ?class-string<Throwable>}[]
*/
public function tokenExpirationProvider(): array {
return [
@ -25,6 +25,8 @@ final class TokenExpirationValidatorTest extends TestCase {
}
/**
* @param ?non-empty-array<string> $claims
* @param ?class-string<Throwable> $throwable
* @dataProvider tokenExpirationProvider
*/
public function testTokenExpiration(int $leeway, int $time, ?array $claims, object $payload, ?string $throwable): void {
@ -41,6 +43,7 @@ final class TokenExpirationValidatorTest extends TestCase {
? new TokenExpirationValidator($leeway, $time)
: new TokenExpirationValidator($leeway, $time, $claims);
$this->assertNull($validator->validate((object)[], $payload));
$validator->validate((object)[], $payload);
$this->addToAssertionCount(1);
}
}