Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

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

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