Skip to content

Commit 2308363

Browse files
authored
feat: add cached keyset (#397)
1 parent 6a6025b commit 2308363

File tree

8 files changed

+761
-27
lines changed

8 files changed

+761
-27
lines changed

.github/workflows/tests.yml

+1
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,6 @@ jobs:
5252
php-version: '8.0'
5353
- name: Run Script
5454
run: |
55+
composer install
5556
composer global require phpstan/phpstan
5657
~/.composer/vendor/bin/phpstan analyse

README.md

+39
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,45 @@ $jwks = ['keys' => []];
203203
JWT::decode($payload, JWK::parseKeySet($jwks));
204204
```
205205

206+
Using Cached Key Sets
207+
---------------------
208+
209+
The `CachedKeySet` class can be used to fetch and cache JWKS (JSON Web Key Sets) from a public URI.
210+
This has the following advantages:
211+
212+
1. The results are cached for performance.
213+
2. If an unrecognized key is requested, the cache is refreshed, to accomodate for key rotation.
214+
3. If rate limiting is enabled, the JWKS URI will not make more than 10 requests a second.
215+
216+
```php
217+
use Firebase\JWT\CachedKeySet;
218+
use Firebase\JWT\JWT;
219+
220+
// The URI for the JWKS you wish to cache the results from
221+
$jwksUri = 'https://www.gstatic.com/iap/verify/public_key-jwk';
222+
223+
// Create an HTTP client (can be any PSR-7 compatible HTTP client)
224+
$httpClient = new GuzzleHttp\Client();
225+
226+
// Create an HTTP request factory (can be any PSR-17 compatible HTTP request factory)
227+
$httpFactory = new GuzzleHttp\Psr\HttpFactory();
228+
229+
// Create a cache item pool (can be any PSR-6 compatible cache item pool)
230+
$cacheItemPool = Phpfastcache\CacheManager::getInstance('files');
231+
232+
$keySet = new CachedKeySet(
233+
$jwksUri,
234+
$httpClient,
235+
$httpFactory,
236+
$cacheItemPool,
237+
null, // $expiresAfter int seconds to set the JWKS to expire
238+
true // $rateLimit true to enable rate limit of 10 RPS on lookup of invalid keys
239+
);
240+
241+
$jwt = 'eyJhbGci...'; // Some JWT signed by a key from the $jwkUri above
242+
$decoded = JWT::decode($jwt, $keySet);
243+
```
244+
206245
Miscellaneous
207246
-------------
208247

composer.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
}
3232
},
3333
"require-dev": {
34-
"phpunit/phpunit": "^7.5||9.5"
34+
"guzzlehttp/guzzle": "^6.5||^7.4",
35+
"phpspec/prophecy-phpunit": "^1.1",
36+
"phpunit/phpunit": "^7.5||^9.5",
37+
"psr/cache": "^1.0||^2.0",
38+
"psr/http-client": "^1.0",
39+
"psr/http-factory": "^1.0"
3540
}
3641
}

phpunit.xml.dist

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
convertWarningsToExceptions="true"
99
processIsolation="false"
1010
stopOnFailure="false"
11-
bootstrap="tests/bootstrap.php"
11+
bootstrap="vendor/autoload.php"
1212
>
1313
<testsuites>
1414
<testsuite name="PHP JSON Web Token Test Suite">

src/CachedKeySet.php

+225
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
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+
public function __construct(
73+
string $jwksUri,
74+
ClientInterface $httpClient,
75+
RequestFactoryInterface $httpFactory,
76+
CacheItemPoolInterface $cache,
77+
int $expiresAfter = null,
78+
bool $rateLimit = false
79+
) {
80+
$this->jwksUri = $jwksUri;
81+
$this->httpClient = $httpClient;
82+
$this->httpFactory = $httpFactory;
83+
$this->cache = $cache;
84+
$this->expiresAfter = $expiresAfter;
85+
$this->rateLimit = $rateLimit;
86+
$this->setCacheKeys();
87+
}
88+
89+
/**
90+
* @param string $keyId
91+
* @return Key
92+
*/
93+
public function offsetGet($keyId): Key
94+
{
95+
if (!$this->keyIdExists($keyId)) {
96+
throw new OutOfBoundsException('Key ID not found');
97+
}
98+
return $this->keySet[$keyId];
99+
}
100+
101+
/**
102+
* @param string $keyId
103+
* @return bool
104+
*/
105+
public function offsetExists($keyId): bool
106+
{
107+
return $this->keyIdExists($keyId);
108+
}
109+
110+
/**
111+
* @param string $offset
112+
* @param Key $value
113+
*/
114+
public function offsetSet($offset, $value): void
115+
{
116+
throw new LogicException('Method not implemented');
117+
}
118+
119+
/**
120+
* @param string $offset
121+
*/
122+
public function offsetUnset($offset): void
123+
{
124+
throw new LogicException('Method not implemented');
125+
}
126+
127+
private function keyIdExists(string $keyId): bool
128+
{
129+
$keySetToCache = null;
130+
if (null === $this->keySet) {
131+
$item = $this->getCacheItem();
132+
// Try to load keys from cache
133+
if ($item->isHit()) {
134+
// item found! Return it
135+
$this->keySet = $item->get();
136+
}
137+
}
138+
139+
if (!isset($this->keySet[$keyId])) {
140+
if ($this->rateLimitExceeded()) {
141+
return false;
142+
}
143+
$request = $this->httpFactory->createRequest('get', $this->jwksUri);
144+
$jwksResponse = $this->httpClient->sendRequest($request);
145+
$jwks = json_decode((string) $jwksResponse->getBody(), true);
146+
$this->keySet = $keySetToCache = JWK::parseKeySet($jwks);
147+
148+
if (!isset($this->keySet[$keyId])) {
149+
return false;
150+
}
151+
}
152+
153+
if ($keySetToCache) {
154+
$item = $this->getCacheItem();
155+
$item->set($keySetToCache);
156+
if ($this->expiresAfter) {
157+
$item->expiresAfter($this->expiresAfter);
158+
}
159+
$this->cache->save($item);
160+
}
161+
162+
return true;
163+
}
164+
165+
private function rateLimitExceeded(): bool
166+
{
167+
if (!$this->rateLimit) {
168+
return false;
169+
}
170+
171+
$cacheItem = $this->cache->getItem($this->rateLimitCacheKey);
172+
if (!$cacheItem->isHit()) {
173+
$cacheItem->expiresAfter(1); // # of calls are cached each minute
174+
}
175+
176+
$callsPerMinute = (int) $cacheItem->get();
177+
if (++$callsPerMinute > $this->maxCallsPerMinute) {
178+
return true;
179+
}
180+
$cacheItem->set($callsPerMinute);
181+
$this->cache->save($cacheItem);
182+
return false;
183+
}
184+
185+
private function getCacheItem(): CacheItemInterface
186+
{
187+
if (\is_null($this->cacheItem)) {
188+
$this->cacheItem = $this->cache->getItem($this->cacheKey);
189+
}
190+
191+
return $this->cacheItem;
192+
}
193+
194+
private function setCacheKeys(): void
195+
{
196+
if (empty($this->jwksUri)) {
197+
throw new RuntimeException('JWKS URI is empty');
198+
}
199+
200+
// ensure we do not have illegal characters
201+
$key = preg_replace('|[^a-zA-Z0-9_\.!]|', '', $this->jwksUri);
202+
203+
// add prefix
204+
$key = $this->cacheKeyPrefix . $key;
205+
206+
// Hash keys if they exceed $maxKeyLength of 64
207+
if (\strlen($key) > $this->maxKeyLength) {
208+
$key = substr(hash('sha256', $key), 0, $this->maxKeyLength);
209+
}
210+
211+
$this->cacheKey = $key;
212+
213+
if ($this->rateLimit) {
214+
// add prefix
215+
$rateLimitKey = $this->cacheKeyPrefix . 'ratelimit' . $key;
216+
217+
// Hash keys if they exceed $maxKeyLength of 64
218+
if (\strlen($rateLimitKey) > $this->maxKeyLength) {
219+
$rateLimitKey = substr(hash('sha256', $rateLimitKey), 0, $this->maxKeyLength);
220+
}
221+
222+
$this->rateLimitCacheKey = $rateLimitKey;
223+
}
224+
}
225+
}

src/JWT.php

+8-11
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22

33
namespace Firebase\JWT;
44

5+
use ArrayAccess;
56
use DateTime;
67
use DomainException;
78
use Exception;
89
use InvalidArgumentException;
910
use OpenSSLAsymmetricKey;
1011
use OpenSSLCertificate;
1112
use stdClass;
12-
use TypeError;
1313
use UnexpectedValueException;
1414

1515
/**
@@ -68,7 +68,7 @@ class JWT
6868
* Decodes a JWT string into a PHP object.
6969
*
7070
* @param string $jwt The JWT
71-
* @param Key|array<string, Key> $keyOrKeyArray The Key or associative array of key IDs (kid) to Key objects.
71+
* @param Key|array<string,Key> $keyOrKeyArray The Key or associative array of key IDs (kid) to Key objects.
7272
* If the algorithm used is asymmetric, this is the public key
7373
* Each Key object contains an algorithm and matching key.
7474
* Supported algorithms are 'ES384','ES256', 'HS256', 'HS384',
@@ -409,7 +409,7 @@ public static function urlsafeB64Encode(string $input): string
409409
/**
410410
* Determine if an algorithm has been provided for each Key
411411
*
412-
* @param Key|array<string, Key> $keyOrKeyArray
412+
* @param Key|ArrayAccess<string,Key>|array<string,Key> $keyOrKeyArray
413413
* @param string|null $kid
414414
*
415415
* @throws UnexpectedValueException
@@ -424,15 +424,12 @@ private static function getKey(
424424
return $keyOrKeyArray;
425425
}
426426

427-
foreach ($keyOrKeyArray as $keyId => $key) {
428-
if (!$key instanceof Key) {
429-
throw new TypeError(
430-
'$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an '
431-
. 'array of Firebase\JWT\Key keys'
432-
);
433-
}
427+
if ($keyOrKeyArray instanceof CachedKeySet) {
428+
// Skip "isset" check, as this will automatically refresh if not set
429+
return $keyOrKeyArray[$kid];
434430
}
435-
if (!isset($kid)) {
431+
432+
if (empty($kid)) {
436433
throw new UnexpectedValueException('"kid" empty, unable to lookup correct key');
437434
}
438435
if (!isset($keyOrKeyArray[$kid])) {

0 commit comments

Comments
 (0)