Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

Differences Between: [Versions 401 and 402] [Versions 402 and 403]

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