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 401 and 402] [Versions 401 and 403]

   1  <?php
   2  
   3  namespace Firebase\JWT;
   4  
   5  use ArrayAccess;
   6  use LogicException;
   7  use OutOfBoundsException;
   8  use Psr\Cache\CacheItemInterface;
   9  use Psr\Cache\CacheItemPoolInterface;
  10  use Psr\Http\Client\ClientInterface;
  11  use Psr\Http\Message\RequestFactoryInterface;
  12  use RuntimeException;
  13  
  14  /**
  15   * @implements ArrayAccess<string, Key>
  16   */
  17  class CachedKeySet implements ArrayAccess
  18  {
  19      /**
  20       * @var string
  21       */
  22      private $jwksUri;
  23      /**
  24       * @var ClientInterface
  25       */
  26      private $httpClient;
  27      /**
  28       * @var RequestFactoryInterface
  29       */
  30      private $httpFactory;
  31      /**
  32       * @var CacheItemPoolInterface
  33       */
  34      private $cache;
  35      /**
  36       * @var ?int
  37       */
  38      private $expiresAfter;
  39      /**
  40       * @var ?CacheItemInterface
  41       */
  42      private $cacheItem;
  43      /**
  44       * @var array<string, Key>
  45       */
  46      private $keySet;
  47      /**
  48       * @var string
  49       */
  50      private $cacheKey;
  51      /**
  52       * @var string
  53       */
  54      private $cacheKeyPrefix = 'jwks';
  55      /**
  56       * @var int
  57       */
  58      private $maxKeyLength = 64;
  59      /**
  60       * @var bool
  61       */
  62      private $rateLimit;
  63      /**
  64       * @var string
  65       */
  66      private $rateLimitCacheKey;
  67      /**
  68       * @var int
  69       */
  70      private $maxCallsPerMinute = 10;
  71      /**
  72       * @var string|null
  73       */
  74      private $defaultAlg;
  75  
  76      public function __construct(
  77          string $jwksUri,
  78          ClientInterface $httpClient,
  79          RequestFactoryInterface $httpFactory,
  80          CacheItemPoolInterface $cache,
  81          int $expiresAfter = null,
  82          bool $rateLimit = false,
  83          string $defaultAlg = null
  84      ) {
  85          $this->jwksUri = $jwksUri;
  86          $this->httpClient = $httpClient;
  87          $this->httpFactory = $httpFactory;
  88          $this->cache = $cache;
  89          $this->expiresAfter = $expiresAfter;
  90          $this->rateLimit = $rateLimit;
  91          $this->defaultAlg = $defaultAlg;
  92          $this->setCacheKeys();
  93      }
  94  
  95      /**
  96       * @param string $keyId
  97       * @return Key
  98       */
  99      public function offsetGet($keyId): Key
 100      {
 101          if (!$this->keyIdExists($keyId)) {
 102              throw new OutOfBoundsException('Key ID not found');
 103          }
 104          return $this->keySet[$keyId];
 105      }
 106  
 107      /**
 108       * @param string $keyId
 109       * @return bool
 110       */
 111      public function offsetExists($keyId): bool
 112      {
 113          return $this->keyIdExists($keyId);
 114      }
 115  
 116      /**
 117       * @param string $offset
 118       * @param Key $value
 119       */
 120      public function offsetSet($offset, $value): void
 121      {
 122          throw new LogicException('Method not implemented');
 123      }
 124  
 125      /**
 126       * @param string $offset
 127       */
 128      public function offsetUnset($offset): void
 129      {
 130          throw new LogicException('Method not implemented');
 131      }
 132  
 133      private function keyIdExists(string $keyId): bool
 134      {
 135          if (null === $this->keySet) {
 136              $item = $this->getCacheItem();
 137              // Try to load keys from cache
 138              if ($item->isHit()) {
 139                  // item found! Return it
 140                  $jwks = $item->get();
 141                  $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg);
 142              }
 143          }
 144  
 145          if (!isset($this->keySet[$keyId])) {
 146              if ($this->rateLimitExceeded()) {
 147                  return false;
 148              }
 149              $request = $this->httpFactory->createRequest('get', $this->jwksUri);
 150              $jwksResponse = $this->httpClient->sendRequest($request);
 151              $jwks = (string) $jwksResponse->getBody();
 152              $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg);
 153  
 154              if (!isset($this->keySet[$keyId])) {
 155                  return false;
 156              }
 157  
 158              $item = $this->getCacheItem();
 159              $item->set($jwks);
 160              if ($this->expiresAfter) {
 161                  $item->expiresAfter($this->expiresAfter);
 162              }
 163              $this->cache->save($item);
 164          }
 165  
 166          return true;
 167      }
 168  
 169      private function rateLimitExceeded(): bool
 170      {
 171          if (!$this->rateLimit) {
 172              return false;
 173          }
 174  
 175          $cacheItem = $this->cache->getItem($this->rateLimitCacheKey);
 176          if (!$cacheItem->isHit()) {
 177              $cacheItem->expiresAfter(1); // # of calls are cached each minute
 178          }
 179  
 180          $callsPerMinute = (int) $cacheItem->get();
 181          if (++$callsPerMinute > $this->maxCallsPerMinute) {
 182              return true;
 183          }
 184          $cacheItem->set($callsPerMinute);
 185          $this->cache->save($cacheItem);
 186          return false;
 187      }
 188  
 189      private function getCacheItem(): CacheItemInterface
 190      {
 191          if (\is_null($this->cacheItem)) {
 192              $this->cacheItem = $this->cache->getItem($this->cacheKey);
 193          }
 194  
 195          return $this->cacheItem;
 196      }
 197  
 198      private function setCacheKeys(): void
 199      {
 200          if (empty($this->jwksUri)) {
 201              throw new RuntimeException('JWKS URI is empty');
 202          }
 203  
 204          // ensure we do not have illegal characters
 205          $key = preg_replace('|[^a-zA-Z0-9_\.!]|', '', $this->jwksUri);
 206  
 207          // add prefix
 208          $key = $this->cacheKeyPrefix . $key;
 209  
 210          // Hash keys if they exceed $maxKeyLength of 64
 211          if (\strlen($key) > $this->maxKeyLength) {
 212              $key = substr(hash('sha256', $key), 0, $this->maxKeyLength);
 213          }
 214  
 215          $this->cacheKey = $key;
 216  
 217          if ($this->rateLimit) {
 218              // add prefix
 219              $rateLimitKey = $this->cacheKeyPrefix . 'ratelimit' . $key;
 220  
 221              // Hash keys if they exceed $maxKeyLength of 64
 222              if (\strlen($rateLimitKey) > $this->maxKeyLength) {
 223                  $rateLimitKey = substr(hash('sha256', $rateLimitKey), 0, $this->maxKeyLength);
 224              }
 225  
 226              $this->rateLimitCacheKey = $rateLimitKey;
 227          }
 228      }
 229  }