Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.

Differences Between: [Versions 310 and 400] [Versions 311 and 400] [Versions 39 and 400] [Versions 400 and 401] [Versions 400 and 402] [Versions 400 and 403]

   1  <?php
   2  
   3  namespace Firebase\JWT;
   4  
   5  use ArrayAccess;
   6  use DomainException;
   7  use Exception;
   8  use InvalidArgumentException;
   9  use OpenSSLAsymmetricKey;
  10  use UnexpectedValueException;
  11  use DateTime;
  12  
  13  /**
  14   * JSON Web Token implementation, based on this spec:
  15   * https://tools.ietf.org/html/rfc7519
  16   *
  17   * PHP version 5
  18   *
  19   * @category Authentication
  20   * @package  Authentication_JWT
  21   * @author   Neuman Vong <neuman@twilio.com>
  22   * @author   Anant Narayanan <anant@php.net>
  23   * @license  http://opensource.org/licenses/BSD-3-Clause 3-clause BSD
  24   * @link     https://github.com/firebase/php-jwt
  25   */
  26  class JWT
  27  {
  28      // const ASN1_INTEGER = 0x02;
  29      // const ASN1_SEQUENCE = 0x10;
  30      // const ASN1_BIT_STRING = 0x03;
  31      private static $asn1Integer = 0x02;
  32      private static $asn1Sequence = 0x10;
  33      private static $asn1BitString = 0x03;
  34  
  35      /**
  36       * When checking nbf, iat or expiration times,
  37       * we want to provide some extra leeway time to
  38       * account for clock skew.
  39       */
  40      public static $leeway = 0;
  41  
  42      /**
  43       * Allow the current timestamp to be specified.
  44       * Useful for fixing a value within unit testing.
  45       *
  46       * Will default to PHP time() value if null.
  47       */
  48      public static $timestamp = null;
  49  
  50      public static $supported_algs = array(
  51          'ES384' => array('openssl', 'SHA384'),
  52          'ES256' => array('openssl', 'SHA256'),
  53          'HS256' => array('hash_hmac', 'SHA256'),
  54          'HS384' => array('hash_hmac', 'SHA384'),
  55          'HS512' => array('hash_hmac', 'SHA512'),
  56          'RS256' => array('openssl', 'SHA256'),
  57          'RS384' => array('openssl', 'SHA384'),
  58          'RS512' => array('openssl', 'SHA512'),
  59          'EdDSA' => array('sodium_crypto', 'EdDSA'),
  60      );
  61  
  62      /**
  63       * Decodes a JWT string into a PHP object.
  64       *
  65       * @param string                    $jwt            The JWT
  66       * @param Key|array<string, Key>    $keyOrKeyArray  The Key or associative array of key IDs (kid) to Key objects.
  67       *                                                  If the algorithm used is asymmetric, this is the public key
  68       *                                                  Each Key object contains an algorithm and matching key.
  69       *                                                  Supported algorithms are 'ES384','ES256', 'HS256', 'HS384',
  70       *                                                  'HS512', 'RS256', 'RS384', and 'RS512'
  71       *
  72       * @return object The JWT's payload as a PHP object
  73       *
  74       * @throws InvalidArgumentException     Provided key/key-array was empty
  75       * @throws DomainException              Provided JWT is malformed
  76       * @throws UnexpectedValueException     Provided JWT was invalid
  77       * @throws SignatureInvalidException    Provided JWT was invalid because the signature verification failed
  78       * @throws BeforeValidException         Provided JWT is trying to be used before it's eligible as defined by 'nbf'
  79       * @throws BeforeValidException         Provided JWT is trying to be used before it's been created as defined by 'iat'
  80       * @throws ExpiredException             Provided JWT has since expired, as defined by the 'exp' claim
  81       *
  82       * @uses jsonDecode
  83       * @uses urlsafeB64Decode
  84       */
  85      public static function decode($jwt, $keyOrKeyArray)
  86      {
  87          // Validate JWT
  88          $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp;
  89  
  90          if (empty($keyOrKeyArray)) {
  91              throw new InvalidArgumentException('Key may not be empty');
  92          }
  93          $tks = \explode('.', $jwt);
  94          if (\count($tks) != 3) {
  95              throw new UnexpectedValueException('Wrong number of segments');
  96          }
  97          list($headb64, $bodyb64, $cryptob64) = $tks;
  98          if (null === ($header = static::jsonDecode(static::urlsafeB64Decode($headb64)))) {
  99              throw new UnexpectedValueException('Invalid header encoding');
 100          }
 101          if (null === $payload = static::jsonDecode(static::urlsafeB64Decode($bodyb64))) {
 102              throw new UnexpectedValueException('Invalid claims encoding');
 103          }
 104          if (false === ($sig = static::urlsafeB64Decode($cryptob64))) {
 105              throw new UnexpectedValueException('Invalid signature encoding');
 106          }
 107          if (empty($header->alg)) {
 108              throw new UnexpectedValueException('Empty algorithm');
 109          }
 110          if (empty(static::$supported_algs[$header->alg])) {
 111              throw new UnexpectedValueException('Algorithm not supported');
 112          }
 113  
 114          $key = self::getKey($keyOrKeyArray, empty($header->kid) ? null : $header->kid);
 115  
 116          // Check the algorithm
 117          if (!self::constantTimeEquals($key->getAlgorithm(), $header->alg)) {
 118              // See issue #351
 119              throw new UnexpectedValueException('Incorrect key for this algorithm');
 120          }
 121          if ($header->alg === 'ES256' || $header->alg === 'ES384') {
 122              // OpenSSL expects an ASN.1 DER sequence for ES256/ES384 signatures
 123              $sig = self::signatureToDER($sig);
 124          }
 125          if (!static::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) {
 126              throw new SignatureInvalidException('Signature verification failed');
 127          }
 128  
 129          // Check the nbf if it is defined. This is the time that the
 130          // token can actually be used. If it's not yet that time, abort.
 131          if (isset($payload->nbf) && $payload->nbf > ($timestamp + static::$leeway)) {
 132              throw new BeforeValidException(
 133                  'Cannot handle token prior to ' . \date(DateTime::ISO8601, $payload->nbf)
 134              );
 135          }
 136  
 137          // Check that this token has been created before 'now'. This prevents
 138          // using tokens that have been created for later use (and haven't
 139          // correctly used the nbf claim).
 140          if (isset($payload->iat) && $payload->iat > ($timestamp + static::$leeway)) {
 141              throw new BeforeValidException(
 142                  'Cannot handle token prior to ' . \date(DateTime::ISO8601, $payload->iat)
 143              );
 144          }
 145  
 146          // Check if this token has expired.
 147          if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) {
 148              throw new ExpiredException('Expired token');
 149          }
 150  
 151          return $payload;
 152      }
 153  
 154      /**
 155       * Converts and signs a PHP object or array into a JWT string.
 156       *
 157       * @param object|array      $payload    PHP object or array
 158       * @param string|resource   $key        The secret key.
 159       *                                      If the algorithm used is asymmetric, this is the private key
 160       * @param string            $alg        The signing algorithm.
 161       *                                      Supported algorithms are 'ES384','ES256', 'HS256', 'HS384',
 162       *                                      'HS512', 'RS256', 'RS384', and 'RS512'
 163       * @param mixed             $keyId
 164       * @param array             $head       An array with header elements to attach
 165       *
 166       * @return string A signed JWT
 167       *
 168       * @uses jsonEncode
 169       * @uses urlsafeB64Encode
 170       */
 171      public static function encode($payload, $key, $alg, $keyId = null, $head = null)
 172      {
 173          $header = array('typ' => 'JWT', 'alg' => $alg);
 174          if ($keyId !== null) {
 175              $header['kid'] = $keyId;
 176          }
 177          if (isset($head) && \is_array($head)) {
 178              $header = \array_merge($head, $header);
 179          }
 180          $segments = array();
 181          $segments[] = static::urlsafeB64Encode(static::jsonEncode($header));
 182          $segments[] = static::urlsafeB64Encode(static::jsonEncode($payload));
 183          $signing_input = \implode('.', $segments);
 184  
 185          $signature = static::sign($signing_input, $key, $alg);
 186          $segments[] = static::urlsafeB64Encode($signature);
 187  
 188          return \implode('.', $segments);
 189      }
 190  
 191      /**
 192       * Sign a string with a given key and algorithm.
 193       *
 194       * @param string            $msg    The message to sign
 195       * @param string|resource   $key    The secret key
 196       * @param string            $alg    The signing algorithm.
 197       *                                  Supported algorithms are 'ES384','ES256', 'HS256', 'HS384',
 198       *                                  'HS512', 'RS256', 'RS384', and 'RS512'
 199       *
 200       * @return string An encrypted message
 201       *
 202       * @throws DomainException Unsupported algorithm or bad key was specified
 203       */
 204      public static function sign($msg, $key, $alg)
 205      {
 206          if (empty(static::$supported_algs[$alg])) {
 207              throw new DomainException('Algorithm not supported');
 208          }
 209          list($function, $algorithm) = static::$supported_algs[$alg];
 210          switch ($function) {
 211              case 'hash_hmac':
 212                  return \hash_hmac($algorithm, $msg, $key, true);
 213              case 'openssl':
 214                  $signature = '';
 215                  $success = \openssl_sign($msg, $signature, $key, $algorithm);
 216                  if (!$success) {
 217                      throw new DomainException("OpenSSL unable to sign data");
 218                  }
 219                  if ($alg === 'ES256') {
 220                      $signature = self::signatureFromDER($signature, 256);
 221                  } elseif ($alg === 'ES384') {
 222                      $signature = self::signatureFromDER($signature, 384);
 223                  }
 224                  return $signature;
 225              case 'sodium_crypto':
 226                  if (!function_exists('sodium_crypto_sign_detached')) {
 227                      throw new DomainException('libsodium is not available');
 228                  }
 229                  try {
 230                      // The last non-empty line is used as the key.
 231                      $lines = array_filter(explode("\n", $key));
 232                      $key = base64_decode(end($lines));
 233                      return sodium_crypto_sign_detached($msg, $key);
 234                  } catch (Exception $e) {
 235                      throw new DomainException($e->getMessage(), 0, $e);
 236                  }
 237          }
 238      }
 239  
 240      /**
 241       * Verify a signature with the message, key and method. Not all methods
 242       * are symmetric, so we must have a separate verify and sign method.
 243       *
 244       * @param string            $msg        The original message (header and body)
 245       * @param string            $signature  The original signature
 246       * @param string|resource   $key        For HS*, a string key works. for RS*, must be a resource of an openssl public key
 247       * @param string            $alg        The algorithm
 248       *
 249       * @return bool
 250       *
 251       * @throws DomainException Invalid Algorithm, bad key, or OpenSSL failure
 252       */
 253      private static function verify($msg, $signature, $key, $alg)
 254      {
 255          if (empty(static::$supported_algs[$alg])) {
 256              throw new DomainException('Algorithm not supported');
 257          }
 258  
 259          list($function, $algorithm) = static::$supported_algs[$alg];
 260          switch ($function) {
 261              case 'openssl':
 262                  $success = \openssl_verify($msg, $signature, $key, $algorithm);
 263                  if ($success === 1) {
 264                      return true;
 265                  } elseif ($success === 0) {
 266                      return false;
 267                  }
 268                  // returns 1 on success, 0 on failure, -1 on error.
 269                  throw new DomainException(
 270                      'OpenSSL error: ' . \openssl_error_string()
 271                  );
 272              case 'sodium_crypto':
 273                if (!function_exists('sodium_crypto_sign_verify_detached')) {
 274                    throw new DomainException('libsodium is not available');
 275                }
 276                try {
 277                    // The last non-empty line is used as the key.
 278                    $lines = array_filter(explode("\n", $key));
 279                    $key = base64_decode(end($lines));
 280                    return sodium_crypto_sign_verify_detached($signature, $msg, $key);
 281                } catch (Exception $e) {
 282                    throw new DomainException($e->getMessage(), 0, $e);
 283                }
 284              case 'hash_hmac':
 285              default:
 286                  $hash = \hash_hmac($algorithm, $msg, $key, true);
 287                  return self::constantTimeEquals($signature, $hash);
 288          }
 289      }
 290  
 291      /**
 292       * Decode a JSON string into a PHP object.
 293       *
 294       * @param string $input JSON string
 295       *
 296       * @return object Object representation of JSON string
 297       *
 298       * @throws DomainException Provided string was invalid JSON
 299       */
 300      public static function jsonDecode($input)
 301      {
 302          if (\version_compare(PHP_VERSION, '5.4.0', '>=') && !(\defined('JSON_C_VERSION') && PHP_INT_SIZE > 4)) {
 303              /** In PHP >=5.4.0, json_decode() accepts an options parameter, that allows you
 304               * to specify that large ints (like Steam Transaction IDs) should be treated as
 305               * strings, rather than the PHP default behaviour of converting them to floats.
 306               */
 307              $obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING);
 308          } else {
 309              /** Not all servers will support that, however, so for older versions we must
 310               * manually detect large ints in the JSON string and quote them (thus converting
 311               *them to strings) before decoding, hence the preg_replace() call.
 312               */
 313              $max_int_length = \strlen((string) PHP_INT_MAX) - 1;
 314              $json_without_bigints = \preg_replace('/:\s*(-?\d{'.$max_int_length.',})/', ': "$1"', $input);
 315              $obj = \json_decode($json_without_bigints);
 316          }
 317  
 318          if ($errno = \json_last_error()) {
 319              static::handleJsonError($errno);
 320          } elseif ($obj === null && $input !== 'null') {
 321              throw new DomainException('Null result with non-null input');
 322          }
 323          return $obj;
 324      }
 325  
 326      /**
 327       * Encode a PHP object into a JSON string.
 328       *
 329       * @param object|array $input A PHP object or array
 330       *
 331       * @return string JSON representation of the PHP object or array
 332       *
 333       * @throws DomainException Provided object could not be encoded to valid JSON
 334       */
 335      public static function jsonEncode($input)
 336      {
 337          if (PHP_VERSION_ID >= 50400) {
 338              $json = \json_encode($input, \JSON_UNESCAPED_SLASHES);
 339          } else {
 340              // PHP 5.3 only
 341              $json = \json_encode($input);
 342          }
 343          if ($errno = \json_last_error()) {
 344              static::handleJsonError($errno);
 345          } elseif ($json === 'null' && $input !== null) {
 346              throw new DomainException('Null result with non-null input');
 347          }
 348          return $json;
 349      }
 350  
 351      /**
 352       * Decode a string with URL-safe Base64.
 353       *
 354       * @param string $input A Base64 encoded string
 355       *
 356       * @return string A decoded string
 357       */
 358      public static function urlsafeB64Decode($input)
 359      {
 360          $remainder = \strlen($input) % 4;
 361          if ($remainder) {
 362              $padlen = 4 - $remainder;
 363              $input .= \str_repeat('=', $padlen);
 364          }
 365          return \base64_decode(\strtr($input, '-_', '+/'));
 366      }
 367  
 368      /**
 369       * Encode a string with URL-safe Base64.
 370       *
 371       * @param string $input The string you want encoded
 372       *
 373       * @return string The base64 encode of what you passed in
 374       */
 375      public static function urlsafeB64Encode($input)
 376      {
 377          return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_'));
 378      }
 379  
 380  
 381      /**
 382       * Determine if an algorithm has been provided for each Key
 383       *
 384       * @param Key|array<string, Key> $keyOrKeyArray
 385       * @param string|null            $kid
 386       *
 387       * @throws UnexpectedValueException
 388       *
 389       * @return array containing the keyMaterial and algorithm
 390       */
 391      private static function getKey($keyOrKeyArray, $kid = null)
 392      {
 393          if ($keyOrKeyArray instanceof Key) {
 394              return $keyOrKeyArray;
 395          }
 396  
 397          if (is_array($keyOrKeyArray) || $keyOrKeyArray instanceof ArrayAccess) {
 398              foreach ($keyOrKeyArray as $keyId => $key) {
 399                  if (!$key instanceof Key) {
 400                      throw new UnexpectedValueException(
 401                          '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an '
 402                          . 'array of Firebase\JWT\Key keys'
 403                      );
 404                  }
 405              }
 406              if (!isset($kid)) {
 407                  throw new UnexpectedValueException('"kid" empty, unable to lookup correct key');
 408              }
 409              if (!isset($keyOrKeyArray[$kid])) {
 410                  throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key');
 411              }
 412  
 413              return $keyOrKeyArray[$kid];
 414          }
 415  
 416          throw new UnexpectedValueException(
 417              '$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an '
 418              . 'array of Firebase\JWT\Key keys'
 419          );
 420      }
 421  
 422      /**
 423       * @param string $left
 424       * @param string $right
 425       * @return bool
 426       */
 427      public static function constantTimeEquals($left, $right)
 428      {
 429          if (\function_exists('hash_equals')) {
 430              return \hash_equals($left, $right);
 431          }
 432          $len = \min(static::safeStrlen($left), static::safeStrlen($right));
 433  
 434          $status = 0;
 435          for ($i = 0; $i < $len; $i++) {
 436              $status |= (\ord($left[$i]) ^ \ord($right[$i]));
 437          }
 438          $status |= (static::safeStrlen($left) ^ static::safeStrlen($right));
 439  
 440          return ($status === 0);
 441      }
 442  
 443      /**
 444       * Helper method to create a JSON error.
 445       *
 446       * @param int $errno An error number from json_last_error()
 447       *
 448       * @return void
 449       */
 450      private static function handleJsonError($errno)
 451      {
 452          $messages = array(
 453              JSON_ERROR_DEPTH => 'Maximum stack depth exceeded',
 454              JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON',
 455              JSON_ERROR_CTRL_CHAR => 'Unexpected control character found',
 456              JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON',
 457              JSON_ERROR_UTF8 => 'Malformed UTF-8 characters' //PHP >= 5.3.3
 458          );
 459          throw new DomainException(
 460              isset($messages[$errno])
 461              ? $messages[$errno]
 462              : 'Unknown JSON error: ' . $errno
 463          );
 464      }
 465  
 466      /**
 467       * Get the number of bytes in cryptographic strings.
 468       *
 469       * @param string $str
 470       *
 471       * @return int
 472       */
 473      private static function safeStrlen($str)
 474      {
 475          if (\function_exists('mb_strlen')) {
 476              return \mb_strlen($str, '8bit');
 477          }
 478          return \strlen($str);
 479      }
 480  
 481      /**
 482       * Convert an ECDSA signature to an ASN.1 DER sequence
 483       *
 484       * @param   string $sig The ECDSA signature to convert
 485       * @return  string The encoded DER object
 486       */
 487      private static function signatureToDER($sig)
 488      {
 489          // Separate the signature into r-value and s-value
 490          list($r, $s) = \str_split($sig, (int) (\strlen($sig) / 2));
 491  
 492          // Trim leading zeros
 493          $r = \ltrim($r, "\x00");
 494          $s = \ltrim($s, "\x00");
 495  
 496          // Convert r-value and s-value from unsigned big-endian integers to
 497          // signed two's complement
 498          if (\ord($r[0]) > 0x7f) {
 499              $r = "\x00" . $r;
 500          }
 501          if (\ord($s[0]) > 0x7f) {
 502              $s = "\x00" . $s;
 503          }
 504  
 505          return self::encodeDER(
 506              self::$asn1Sequence,
 507              self::encodeDER(self::$asn1Integer, $r) .
 508              self::encodeDER(self::$asn1Integer, $s)
 509          );
 510      }
 511  
 512      /**
 513       * Encodes a value into a DER object.
 514       *
 515       * @param   int     $type DER tag
 516       * @param   string  $value the value to encode
 517       * @return  string  the encoded object
 518       */
 519      private static function encodeDER($type, $value)
 520      {
 521          $tag_header = 0;
 522          if ($type === self::$asn1Sequence) {
 523              $tag_header |= 0x20;
 524          }
 525  
 526          // Type
 527          $der = \chr($tag_header | $type);
 528  
 529          // Length
 530          $der .= \chr(\strlen($value));
 531  
 532          return $der . $value;
 533      }
 534  
 535      /**
 536       * Encodes signature from a DER object.
 537       *
 538       * @param   string  $der binary signature in DER format
 539       * @param   int     $keySize the number of bits in the key
 540       * @return  string  the signature
 541       */
 542      private static function signatureFromDER($der, $keySize)
 543      {
 544          // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE
 545          list($offset, $_) = self::readDER($der);
 546          list($offset, $r) = self::readDER($der, $offset);
 547          list($offset, $s) = self::readDER($der, $offset);
 548  
 549          // Convert r-value and s-value from signed two's compliment to unsigned
 550          // big-endian integers
 551          $r = \ltrim($r, "\x00");
 552          $s = \ltrim($s, "\x00");
 553  
 554          // Pad out r and s so that they are $keySize bits long
 555          $r = \str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT);
 556          $s = \str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT);
 557  
 558          return $r . $s;
 559      }
 560  
 561      /**
 562       * Reads binary DER-encoded data and decodes into a single object
 563       *
 564       * @param string $der the binary data in DER format
 565       * @param int $offset the offset of the data stream containing the object
 566       * to decode
 567       * @return array [$offset, $data] the new offset and the decoded object
 568       */
 569      private static function readDER($der, $offset = 0)
 570      {
 571          $pos = $offset;
 572          $size = \strlen($der);
 573          $constructed = (\ord($der[$pos]) >> 5) & 0x01;
 574          $type = \ord($der[$pos++]) & 0x1f;
 575  
 576          // Length
 577          $len = \ord($der[$pos++]);
 578          if ($len & 0x80) {
 579              $n = $len & 0x1f;
 580              $len = 0;
 581              while ($n-- && $pos < $size) {
 582                  $len = ($len << 8) | \ord($der[$pos++]);
 583              }
 584          }
 585  
 586          // Value
 587          if ($type == self::$asn1BitString) {
 588              $pos++; // Skip the first contents octet (padding indicator)
 589              $data = \substr($der, $pos, $len - 1);
 590              $pos += $len - 1;
 591          } elseif (!$constructed) {
 592              $data = \substr($der, $pos, $len);
 593              $pos += $len;
 594          } else {
 595              $data = null;
 596          }
 597  
 598          return array($pos, $data);
 599      }
 600  }