See Release Notes
Long Term Support Release
<?php namespace Firebase\JWT; use ArrayAccess;> use InvalidArgumentException;use LogicException; use OutOfBoundsException; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use RuntimeException;> use UnexpectedValueException;/** * @implements ArrayAccess<string, Key> */ class CachedKeySet implements ArrayAccess { /** * @var string */ private $jwksUri; /** * @var ClientInterface */ private $httpClient; /** * @var RequestFactoryInterface */ private $httpFactory; /** * @var CacheItemPoolInterface */ private $cache; /** * @var ?int */ private $expiresAfter; /** * @var ?CacheItemInterface */ private $cacheItem; /**< * @var array<string, Key>> * @var array<string, array<mixed>>*/ private $keySet; /** * @var string */ private $cacheKey; /** * @var string */ private $cacheKeyPrefix = 'jwks'; /** * @var int */ private $maxKeyLength = 64; /** * @var bool */ private $rateLimit; /** * @var string */ private $rateLimitCacheKey; /** * @var int */ private $maxCallsPerMinute = 10; /** * @var string|null */ private $defaultAlg; public function __construct( string $jwksUri, ClientInterface $httpClient, RequestFactoryInterface $httpFactory, CacheItemPoolInterface $cache, int $expiresAfter = null, bool $rateLimit = false, string $defaultAlg = null ) { $this->jwksUri = $jwksUri; $this->httpClient = $httpClient; $this->httpFactory = $httpFactory; $this->cache = $cache; $this->expiresAfter = $expiresAfter; $this->rateLimit = $rateLimit; $this->defaultAlg = $defaultAlg; $this->setCacheKeys(); } /** * @param string $keyId * @return Key */ public function offsetGet($keyId): Key { if (!$this->keyIdExists($keyId)) { throw new OutOfBoundsException('Key ID not found'); }< return $this->keySet[$keyId];> return JWK::parseKey($this->keySet[$keyId], $this->defaultAlg);} /** * @param string $keyId * @return bool */ public function offsetExists($keyId): bool { return $this->keyIdExists($keyId); } /** * @param string $offset * @param Key $value */ public function offsetSet($offset, $value): void { throw new LogicException('Method not implemented'); } /** * @param string $offset */ public function offsetUnset($offset): void { throw new LogicException('Method not implemented'); }> /** private function keyIdExists(string $keyId): bool > * @return array<mixed> { > */ if (null === $this->keySet) { > private function formatJwksForCache(string $jwks): array $item = $this->getCacheItem(); > { // Try to load keys from cache > $jwks = json_decode($jwks, true); if ($item->isHit()) { > // item found! Return it > if (!isset($jwks['keys'])) { $jwks = $item->get(); > throw new UnexpectedValueException('"keys" member must exist in the JWK Set'); $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg); > } } > } > if (empty($jwks['keys'])) { > throw new InvalidArgumentException('JWK Set did not contain any keys'); if (!isset($this->keySet[$keyId])) { > } if ($this->rateLimitExceeded()) { > return false; > $keys = []; } > foreach ($jwks['keys'] as $k => $v) { $request = $this->httpFactory->createRequest('get', $this->jwksUri); > $kid = isset($v['kid']) ? $v['kid'] : $k; $jwksResponse = $this->httpClient->sendRequest($request); > $keys[(string) $kid] = $v; $jwks = (string) $jwksResponse->getBody(); > } $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg); > > return $keys; if (!isset($this->keySet[$keyId])) { > } return false; >< // item found! Return it < $jwks = $item->get(); < $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg);> // item found! retrieve it > $this->keySet = $item->get(); > // If the cached item is a string, the JWKS response was cached (previous behavior). > // Parse this into expected format array<kid, jwk> instead. > if (\is_string($this->keySet)) { > $this->keySet = $this->formatJwksForCache($this->keySet); > }< $request = $this->httpFactory->createRequest('get', $this->jwksUri);> $request = $this->httpFactory->createRequest('GET', $this->jwksUri);< $jwks = (string) $jwksResponse->getBody(); < $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg);> if ($jwksResponse->getStatusCode() !== 200) { > throw new UnexpectedValueException( > sprintf('HTTP Error: %d %s for URI "%s"', > $jwksResponse->getStatusCode(), > $jwksResponse->getReasonPhrase(), > $this->jwksUri, > ), > $jwksResponse->getStatusCode() > ); > } > $this->keySet = $this->formatJwksForCache((string) $jwksResponse->getBody());< $item->set($jwks);> $item->set($this->keySet);$this->cache->save($item); } return true; } private function rateLimitExceeded(): bool { if (!$this->rateLimit) { return false; } $cacheItem = $this->cache->getItem($this->rateLimitCacheKey); if (!$cacheItem->isHit()) { $cacheItem->expiresAfter(1); // # of calls are cached each minute } $callsPerMinute = (int) $cacheItem->get(); if (++$callsPerMinute > $this->maxCallsPerMinute) { return true; } $cacheItem->set($callsPerMinute); $this->cache->save($cacheItem); return false; } private function getCacheItem(): CacheItemInterface { if (\is_null($this->cacheItem)) { $this->cacheItem = $this->cache->getItem($this->cacheKey); } return $this->cacheItem; } private function setCacheKeys(): void { if (empty($this->jwksUri)) { throw new RuntimeException('JWKS URI is empty'); } // ensure we do not have illegal characters $key = preg_replace('|[^a-zA-Z0-9_\.!]|', '', $this->jwksUri); // add prefix $key = $this->cacheKeyPrefix . $key; // Hash keys if they exceed $maxKeyLength of 64 if (\strlen($key) > $this->maxKeyLength) { $key = substr(hash('sha256', $key), 0, $this->maxKeyLength); } $this->cacheKey = $key; if ($this->rateLimit) { // add prefix $rateLimitKey = $this->cacheKeyPrefix . 'ratelimit' . $key; // Hash keys if they exceed $maxKeyLength of 64 if (\strlen($rateLimitKey) > $this->maxKeyLength) { $rateLimitKey = substr(hash('sha256', $rateLimitKey), 0, $this->maxKeyLength); } $this->rateLimitCacheKey = $rateLimitKey; } } }