Revamped static Jwt class.

This commit is contained in:
flash 2025-05-13 17:44:49 +02:00
parent 9ef1ec08a4
commit 23d1b4d511
Signed by: flash
GPG key ID: 6D833BE0D210AC02
3 changed files with 139 additions and 121 deletions

View file

@ -21,6 +21,27 @@ namespace {
}
}
if(!function_exists('array_any')) {
/**
* Checks if at least one array element satisfies a callback function.
*
* @param mixed[] $array The array that should be searched.
* @param callable(mixed $value, mixed $key): bool $callback The callback function to call to check each element.
* @throws InvalidArgumentException If any of the argument types were not as expected.
* @return bool The function returns true, if callback returns true for all elements. Otherwise returns false.
*/
function array_any(array $array, $callback): bool {
if(!is_callable($callback))
throw new InvalidArgumentException('$callback must be a callable');
foreach($array as $key => $value)
if($callback($value, $key))
return true;
return false;
}
}
if(!function_exists('array_key_first')) {
/**
* Gets the first key of an array.

View file

@ -1,156 +1,151 @@
<?php
namespace Railgun\Jwt;
use Exception;
use InvalidArgumentException;
use RuntimeException;
use UnexpectedValueException;
use Railgun\Jwt\Utility\{Json,UriBase64};
use Railgun\Jwt\Validators\{NotValidBeforeValidator,TokenExpirationValidator};
/**
* JSON Web Token codec.
*
* @todo Rewrite this to juggle JwtEncoder instances
*
* Loosely based on https://github.com/firebase/php-jwt/blob/8f718f4dfc9c5d5f0c994cdfd103921b43592712/src/JWT.php
*/
class Jwt {
/** @var array<string, JwtEncoder> */
private static $codecs = [];
/**
* Encodes a JWT.
* Converts an input value to a substitute for the time() function.
*
* @param array<string, mixed>|object $payload JWT payload section.
* @param Jwk $key Key used to generate the signature.
* @param ?string $alg Algorithm to sign the token with, must be supported by $key. If null, $key is expected to return a default value.
* @param array<string, mixed>|object|null $headers Additional JWT header data, may not contain typ, alg or kid.
* @throws InvalidArgumentException If any of the argument types were not as expected.
* @return string
* @param mixed $time
* @return ?(callable(): int)
*/
public static function encode(
$payload,
Jwk $key,
$alg = null,
$headers = null
) {
if(!is_array($payload) && !is_object($payload))
throw new InvalidArgumentException('$payload must be an array or an object');
if($headers === null) {
$headers = [];
} else {
if(!is_array($headers) && !is_object($headers))
throw new InvalidArgumentException('$headers must be null, an array or an object');
private static function createTimeCallable($time) {
if(is_int($time))
return function() use ($time) { return $time; };
$headers = (array)$headers;
if(is_callable($time))
return $time;
return null;
}
/**
* Gets a JwtEncoder based on options provided.
*
* @param array<string, mixed> $options Codec options to use.
* @throws InvalidArgumentException If a part of $options could not be parsed.
* @return JwtEncoder
*/
private static function resolveCodec(array $options): JwtEncoder {
try {
$serialized = serialize($options);
if(array_key_exists($serialized, self::$codecs))
return self::$codecs[$serialized];
} catch(Exception $ex) {
$serialized = null;
}
if(isset($headers['typ']) || isset($headers['alg']) || isset($headers['kid']))
throw new InvalidArgumentException('typ, alg and/or kid may not appear in $headers');
$keys = null;
$validators = [];
// I want typ, alg and kid to appear before everything else even though it doesn't really matter
$tmp = $headers;
$headers = ['typ' => 'JWT'];
if($alg === null) {
$algo = $key->getAlgorithm();
if($algo === null)
throw new InvalidArgumentException('$key did not provide a default algorithm, $alg must be specified');
$headers['alg'] = $algo;
} else {
if(!is_string($alg))
throw new InvalidArgumentException('$alg must be a string or null');
$headers['alg'] = $alg;
if(array_key_exists('keys', $options)) {
if($options['keys'] instanceof JwkSet)
$keys = $options['keys'];
elseif(is_string($options['keys']) || is_array($options['keys']) || is_object($options['keys'])) {
$keysLoader = array_key_exists('keys_loader', $options)
&& $options['keys_loader'] instanceof JwkSetLoader
? $options['keys_loader'] : HttpJwkSetLoader::instance();
$keys = $keysLoader->loadJwkSet($options['keys']); // @phpstan-ignore-line
} else
throw new InvalidArgumentException('$options[\'keys\'] could not be understood');
}
$keyId = $key->getId();
if($keyId !== null)
$headers['kid'] = $keyId;
if(array_key_exists('validators', $options) && is_array($options['validators']))
foreach($options['validators'] as $validator)
if($validator instanceof JwtValidator)
$validators[] = $validator;
$headers = Json::encode(array_merge($headers, $tmp));
$payload = Json::encode($payload);
$leeway = 0;
if(array_key_exists('leeway', $options) && is_int($options['leeway']))
$leeway = $options['leeway'];
$encoded = sprintf('%s.%s', UriBase64::encode($headers), UriBase64::encode($payload));
$time = null;
if(array_key_exists('time', $options))
$time = self::createTimeCallable($options['time']);
$signature = $key->sign($encoded, $alg);
$encoded = sprintf('%s.%s', $encoded, UriBase64::encode($signature));
if(array_key_exists('check_expired', $options)
&& (is_scalar($options['check_expired']) ? $options['check_expired'] : true)
&& !array_any($validators, function($value) { return $value instanceof TokenExpirationValidator; })) {
$expiresTime = $time;
$expiresClaims = null;
return $encoded;
if(is_int($options['check_expired']) || is_callable($options['check_expired']))
$expiresTime = self::createTimeCallable($options['check_expired']);
elseif(is_array($options['check_expired']) && !empty($options['check_expired']))
$expiresClaims = $options['check_expired'];
$validators[] = $expiresClaims === null
? new TokenExpirationValidator($leeway, $expiresTime)
: new TokenExpirationValidator($leeway, $expiresTime, $expiresClaims); // @phpstan-ignore-line
}
if(array_key_exists('check_valid', $options)
&& (is_scalar($options['check_valid']) ? $options['check_valid'] : true)
&& !array_any($validators, function($value) { return $value instanceof NotValidBeforeValidator; })) {
$notValidBeforeTime = $time;
$notValidBeforeClaims = null;
if(is_int($options['check_valid']) || is_callable($options['check_valid']))
$notValidBeforeTime = self::createTimeCallable($options['check_valid']);
elseif(is_array($options['check_valid']) && !empty($options['check_valid']))
$notValidBeforeClaims = $options['check_valid'];
$validators[] = $notValidBeforeClaims === null
? new NotValidBeforeValidator($leeway, $notValidBeforeTime)
: new NotValidBeforeValidator($leeway, $notValidBeforeTime, $notValidBeforeClaims); // @phpstan-ignore-line
}
$codec = new JwtEncoder($keys, $validators);
if($serialized !== null)
self::$codecs[$serialized] = $codec;
return $codec;
}
/**
* Decodes and verifies a JWT.
*
* @param string $token Token to be decoded.
* @param JwkSet $keys Collection of keys that are allowed to verification.
* @param array<string, mixed>|object|null $headers Overrides to apply to the header part of the JWT.
* @param ?int $timestamp Timestamp with which to check for validity of the JWT.
* @param int $leeway Amount of seconds of tolerance to apply on the timestamp, in either direction.
* @param array<string, mixed>|object|null $headers JWT header section override.
* @param bool $mergeHeaders Whether $headers should be merged with the actual provided headers in the token (true), or replace them entirely (false).
* @param array<string, mixed> $options Decoder options to use.
* @throws InvalidArgumentException If any of the argument types were not as expected.
* @throws UnexpectedValueException If any of the methods in $keys returned unexpected values.
* @return mixed[]|object Payload of the JWT.
* @return object Payload of the JWT.
*/
public static function decode(
$token,
JwkSet $keys,
string $token,
$headers = null,
$timestamp = null,
$leeway = 0
bool $mergeHeaders = true,
array $options = []
) {
if(!is_string($token))
throw new InvalidArgumentException('$token must be a string');
if($headers !== null && !is_array($headers) && !is_object($headers))
throw new InvalidArgumentException('$headers must be null, an array or an object');
if($timestamp === null)
$timestamp = time();
elseif(!is_int($timestamp))
throw new InvalidArgumentException('$timestamp must be null or an integer');
if(!is_int($leeway))
throw new InvalidArgumentException('$leeway must be an integer');
return self::resolveCodec($options)->decode($token, $headers, $mergeHeaders);
}
$parts = explode('.', $token, 4);
if(count($parts) !== 3)
throw new InvalidArgumentException('$token does not contain an acceptable amount of parts');
list($headerEnc, $payloadEnc, $sigEnc) = $parts;
$headerRaw = UriBase64::decode($headerEnc);
$headerDec = Json::decode($headerRaw);
$headers = $headers === null ? $headerDec : (object)$headers;
if(!is_object($headers))
throw new UnexpectedValueException('$token header part must be a JSON object');
if(empty($headers->alg) || !is_string($headers->alg))
throw new UnexpectedValueException('alg not set in $token');
$alg = $headers->alg;
$kid = !empty($headers->kid) && is_string($headers->kid) ? $headers->kid : null;
$payloadRaw = UriBase64::decode($payloadEnc);
$payload = Json::decode($payloadRaw);
if(!is_object($payload))
throw new UnexpectedValueException('$token payload part must be a JSON object');
$key = $keys->getKey($kid, $alg);
$sig = UriBase64::decode($sigEnc);
$enc = sprintf('%s.%s', $headerEnc, $payloadEnc);
if(!$key->verify($enc, $sig, $alg))
throw new RuntimeException('could not verify signature of $token');
// move these to a chaining system
if(isset($payload->nbf) && (is_int($payload->nbf) || is_float($payload->nbf)))
$nbf = (int)floor($payload->nbf);
elseif(isset($payload->iat) && (is_int($payload->iat) || is_float($payload->iat)))
$nbf = (int)floor($payload->iat);
else
$nbf = null;
if($nbf !== null && $nbf > ($timestamp + $leeway))
throw new RuntimeException(sprintf('token is not valid before %s', date(DATE_ATOM, $nbf)));
if(isset($payload->exp) && (is_int($payload->exp) || is_float($payload->exp))) {
$exp = (int)$payload->exp;
if($exp < ($timestamp - $leeway))
throw new RuntimeException(sprintf('token expired at %s', date(DATE_ATOM, $exp)));
}
return $payload;
/**
* Encodes a JWT.
*
* @param array<string, mixed>|object $payload JWT payload section.
* @param array<string, mixed>|object|null $headers JWT header section.
* @param array<string, mixed> $options Encoder options to use.
* @throws InvalidArgumentException If any of the argument types were not as expected.
* @return string
*/
public static function encode(
$payload,
$headers = null,
array $options = []
) {
return self::resolveCodec($options)->encode($payload, $headers);
}
}

View file

@ -17,15 +17,17 @@ class JwtEncoder {
private $validators;
/**
* @param JwkSet $keys Collection of keys that are allowed to verification.
* @param ?JwkSet $keys Collection of keys that are allowed to verification.
* @param JwtValidator[] $validators Collection of validators to run when decoding.
*/
public function __construct(
JwkSet $keys,
?JwkSet $keys = null,
array $validators = []
) {
if(!array_all($validators, function($item) { return $item instanceof JwtValidator; })) // @phpstan-ignore instanceof.alwaysTrue
throw new InvalidArgumentException('all items of $validators must implement JwtValidator');
if($keys === null)
$keys = new NoneJwkSet;
$this->keys = $keys;
$this->validators = $validators;