Skip to content

Commit da38887

Browse files
author
Evan Sims
authored
[SDK-2141] Expand control of ttl/caching in JWKFetcher (#462)
1 parent f3d694b commit da38887

File tree

2 files changed

+209
-5
lines changed

2 files changed

+209
-5
lines changed

src/Helpers/JWKFetcher.php

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
use Auth0\SDK\API\Helpers\RequestBuilder;
77
use Auth0\SDK\Helpers\Cache\NoCacheHandler;
8+
use Auth0\SDK\Exception\CoreException;
89
use GuzzleHttp\Exception\ClientException;
910
use GuzzleHttp\Exception\RequestException;
1011
use Psr\SimpleCache\CacheInterface;
@@ -17,19 +18,35 @@
1718
class JWKFetcher
1819
{
1920
/**
20-
* How long should the cache persist? Set to 10 minutes.
21+
* Default length of cache persistence. Defaults to 10 minutes.
2122
*
2223
* @see https://www.php-fig.org/psr/psr-16/#12-definitions
2324
*/
2425
const CACHE_TTL = 600;
2526

27+
/**
28+
* How long should the cache persist? Defaults to value of CACHE_TTL.
29+
* We strongly encouraged you leave the default value.
30+
*
31+
* @see https://www.php-fig.org/psr/psr-16/#12-definitions
32+
*/
33+
private $ttl = self::CACHE_TTL;
34+
2635
/**
2736
* Cache handler or null for no caching.
2837
*
2938
* @var CacheInterface|null
3039
*/
3140
private $cache;
3241

42+
/**
43+
* Cache for unique cache ids.
44+
* Key for each entry is the url to the JWK. Value is cache id.
45+
*
46+
* @var array
47+
*/
48+
private $cachedEntryIds = [];
49+
3350
/**
3451
* Options for the Guzzle HTTP client.
3552
*
@@ -42,15 +59,20 @@ class JWKFetcher
4259
*
4360
* @param CacheInterface|null $cache Cache handler or null for no caching.
4461
* @param array $guzzleOptions Guzzle HTTP options.
62+
* @param options $options Class options to apply at initializion.
4563
*/
46-
public function __construct(CacheInterface $cache = null, array $guzzleOptions = [])
64+
public function __construct(CacheInterface $cache = null, array $guzzleOptions = [], array $options = [])
4765
{
4866
if ($cache === null) {
4967
$cache = new NoCacheHandler();
5068
}
5169

5270
$this->cache = $cache;
5371
$this->guzzleOptions = $guzzleOptions;
72+
73+
if (!empty($options['ttl'])) {
74+
$this->setTtl($options['ttl']);
75+
}
5476
}
5577

5678
/**
@@ -98,12 +120,13 @@ public function getKey(string $kid, string $jwksUri = null)
98120
public function getKeys(string $jwks_url = null, bool $use_cache = true) : array
99121
{
100122
$jwks_url = $jwks_url ?? $this->guzzleOptions['base_uri'] ?? '';
123+
101124
if (empty( $jwks_url )) {
102125
return [];
103126
}
104127

105-
$cache_key = md5($jwks_url);
106-
$cached_value = $use_cache ? $this->cache->get($cache_key) : null;
128+
$cached_value = $use_cache ? $this->getCacheEntry($jwks_url) : null;
129+
107130
if (! empty($cached_value) && is_array($cached_value)) {
108131
return $cached_value;
109132
}
@@ -123,7 +146,7 @@ public function getKeys(string $jwks_url = null, bool $use_cache = true) : array
123146
$keys[$key['kid']] = $this->convertCertToPem( $key['x5c'][0] );
124147
}
125148

126-
$this->cache->set($cache_key, $keys, self::CACHE_TTL);
149+
$this->setCacheEntry($jwks_url, $keys);
127150
return $keys;
128151
}
129152

@@ -148,4 +171,111 @@ protected function requestJwks(string $jwks_url) : array
148171

149172
return $request->call();
150173
}
174+
175+
/**
176+
* Set how long to cache JWKs in seconds.
177+
* We strongly encouraged you leave the default value.
178+
*
179+
* @param string $ttlSeconds Number of seconds to keep a JWK in memory.
180+
*
181+
* @return $this
182+
*
183+
* @throws CoreException If $ttlSeconds is less than 60.
184+
*/
185+
public function setTtl(int $ttlSeconds)
186+
{
187+
if ($ttlSeconds < 60) {
188+
throw new CoreException('TTL cannot be less than 60 seconds.');
189+
}
190+
191+
$this->ttl = $ttlSeconds;
192+
return $this;
193+
}
194+
195+
/**
196+
* Returns how long we are caching JWKs in seconds.
197+
*
198+
* @return integer
199+
*/
200+
public function getTtl()
201+
{
202+
return $this->ttl;
203+
}
204+
205+
/**
206+
* Generate a cache id to use for a URL.
207+
*
208+
* @param string $jwks_url Full URL to the JWKS.
209+
*
210+
* @return string
211+
*/
212+
public function getCacheKey(string $jwksUri)
213+
{
214+
if (isset($this->cachedEntryIds[$jwksUri])) {
215+
return $this->cachedEntryIds[$jwksUri];
216+
}
217+
218+
$cacheKey = md5($jwksUri);
219+
220+
$this->cachedEntryIds[$jwksUri] = $cacheKey;
221+
return $cacheKey;
222+
}
223+
224+
/**
225+
* Get a specific JWK from the cache by it's URL.
226+
*
227+
* @param string $jwks_url Full URL to the JWKS.
228+
*
229+
* @return null|array
230+
*/
231+
public function getCacheEntry(string $jwksUri)
232+
{
233+
$cache_key = $this->getCacheKey($jwksUri);
234+
$cached_value = $this->cache->get($cache_key);
235+
236+
if (! empty($cached_value) && is_array($cached_value)) {
237+
return $cached_value;
238+
}
239+
240+
return null;
241+
}
242+
243+
/**
244+
* Add or overwrite a specific JWK from the cache.
245+
*
246+
* @param string $jwks_url Full URL to the JWKS.
247+
* @param array $keys An array representing the JWKS.
248+
*
249+
* @return $this
250+
*/
251+
public function setCacheEntry(string $jwksUri, array $keys)
252+
{
253+
$cache_key = $this->getCacheKey($jwksUri);
254+
255+
$this->cache->set($cache_key, $keys, $this->ttl);
256+
257+
return $this;
258+
}
259+
260+
/**
261+
* Remove a specific JWK from the cache by it's URL.
262+
*
263+
* @param string $jwks_url Full URL to the JWKS.
264+
*
265+
* @return boolean
266+
*/
267+
public function removeCacheEntry(string $jwksUri)
268+
{
269+
return $this->cache->delete($this->getCacheKey($jwksUri));
270+
}
271+
272+
/**
273+
* Clear the JWK cache.
274+
*
275+
* @return boolean
276+
*/
277+
public function clearCache()
278+
{
279+
return $this->cache->clear();
280+
}
151281
}

tests/unit/Helpers/JWKFetcherTest.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Cache\Adapter\PHPArray\ArrayCachePool;
66
use GuzzleHttp\Psr7\Response;
77
use PHPUnit\Framework\TestCase;
8+
use Auth0\SDK\Exception\CoreException;
89

910
/**
1011
* Class JWKFetcherTest.
@@ -142,4 +143,77 @@ public function testThatEmptyUrlReturnsEmptyKeys() {
142143
$jwks_formatted_1 = (new JWKFetcher())->getKeys();
143144
$this->assertEquals( [], $jwks_formatted_1 );
144145
}
146+
147+
public function testThatTtlChanges() {
148+
$jwks_body = '{"keys":[{"kid":"__test_kid_2__","x5c":["__test_x5c_2__"]}]}';
149+
$jwks = new MockJwks(
150+
// [ new Response( 200, [ 'Content-Type' => 'application/json' ], $jwks_body ) ],
151+
// [ 'cache' => new ArrayCachePool() ],
152+
// [ 'base_uri' => '__test_jwks_url__' ]
153+
);
154+
155+
// Ensure TTL is assigned a recommended default value of 10 minutes.
156+
$this->assertEquals(JWKFetcher::CACHE_TTL, $jwks->call()->getTtl());
157+
158+
// Ensure TTL is assigned correctly; 60 seconds is the minimum.
159+
$jwks->call()->setTtl(60);
160+
$this->assertEquals(60, $jwks->call()->getTtl());
161+
162+
// Ensure assigning a TTL of less than 60 seconds throws an exception.
163+
$this->expectException(CoreException::class);
164+
$jwks->call()->setTtl(30);
165+
}
166+
167+
public function testThatCacheMutates() {
168+
$jwks_body = '{"keys":[{"kid":"__kid_1__","x5c":["__x5c_1__"]},{"kid":"__kid_2__","x5c":["__x5c_2__"]}]}';
169+
$jwks_body_modified = ['__kid_3__' => '__x5c_3__', '__kid_4__' => '__x5c_4__'];
170+
171+
$jwks = new MockJwks(
172+
[
173+
new Response( 200, [ 'Content-Type' => 'application/json' ], $jwks_body ),
174+
new Response( 200, [ 'Content-Type' => 'application/json' ], $jwks_body ),
175+
],
176+
[ 'cache' => new ArrayCachePool() ],
177+
[ 'base_uri' => '__test_jwks_url__' ]
178+
);
179+
180+
$jwks->call()->getKeys('__test_jwks_url__', false);
181+
182+
// getCacheKey MUST return an MD5 hash of provided JWKS URL.
183+
$this->assertEquals(md5('__test_jwks_url__'), $jwks->call()->getCacheKey('__test_jwks_url__'));
184+
185+
// Requests for invalid/missing kids MUST return NULL.
186+
$this->assertEquals(null, $jwks->call()->getKey('__test_missing_kid__'));
187+
188+
// Requesting a valid kid MUST return an ARRAY containing the x5c
189+
$this->assertContains('__x5c_1__', $jwks->call()->getKey('__kid_1__'));
190+
191+
// Pulling directly from cache will return same results as getKeys()
192+
$this->assertArrayHasKey('__kid_1__', $jwks->call()->getCacheEntry('__test_jwks_url__'));
193+
194+
// Overwrite an existing JWK in the cache.
195+
$jwks->call()->setCacheEntry('__test_jwks_url__', $jwks_body_modified);
196+
197+
// Inject a new JWK into the cache.
198+
$jwks->call()->setCacheEntry('__test_jwks_url_2__', $jwks_body_modified);
199+
200+
// Ensure the cache was updated successfully
201+
$this->assertArrayHasKey('__kid_3__', $jwks->call()->getCacheEntry('__test_jwks_url__'));
202+
203+
// Purge the cache of keys relating to our test url
204+
$jwks->call()->removeCacheEntry('__test_jwks_url__');
205+
206+
// Ensure cache for our test url is empty
207+
$this->assertEmpty($jwks->call()->getCacheEntry('__test_jwks_url__'));
208+
209+
// Ensure the cache still contains content for __test_jwks_url_2__
210+
$this->assertArrayHasKey('__kid_3__', $jwks->call()->getCacheEntry('__test_jwks_url_2__'));
211+
212+
// Purge the cache of all keys
213+
$jwks->call()->clearCache();
214+
215+
// Ensure all JWKs are cleared from the cache.
216+
$this->assertEmpty($jwks->call()->getCacheEntry('__test_jwks_url__'));
217+
$this->assertEmpty($jwks->call()->getCacheEntry('__test_jwks_url_2__'));
218+
}
145219
}

0 commit comments

Comments
 (0)