Revamped static Jwt class.
This commit is contained in:
parent
9ef1ec08a4
commit
23d1b4d511
3 changed files with 139 additions and 121 deletions
21
polyfill.php
21
polyfill.php
|
@ -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.
|
||||
|
|
233
src/Jwt.php
233
src/Jwt.php
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue