Skip to content

Commit 78d3ed1

Browse files
authored
feat: improve caching by only decoding jwks when necessary (#486)
1 parent 08c7ba6 commit 78d3ed1

File tree

2 files changed

+109
-13
lines changed

2 files changed

+109
-13
lines changed

Diff for: src/CachedKeySet.php

+37-8
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
namespace Firebase\JWT;
44

55
use ArrayAccess;
6+
use InvalidArgumentException;
67
use LogicException;
78
use OutOfBoundsException;
89
use Psr\Cache\CacheItemInterface;
910
use Psr\Cache\CacheItemPoolInterface;
1011
use Psr\Http\Client\ClientInterface;
1112
use Psr\Http\Message\RequestFactoryInterface;
1213
use RuntimeException;
14+
use UnexpectedValueException;
1315

1416
/**
1517
* @implements ArrayAccess<string, Key>
@@ -41,7 +43,7 @@ class CachedKeySet implements ArrayAccess
4143
*/
4244
private $cacheItem;
4345
/**
44-
* @var array<string, Key>
46+
* @var array<string, array<mixed>>
4547
*/
4648
private $keySet;
4749
/**
@@ -101,7 +103,7 @@ public function offsetGet($keyId): Key
101103
if (!$this->keyIdExists($keyId)) {
102104
throw new OutOfBoundsException('Key ID not found');
103105
}
104-
return $this->keySet[$keyId];
106+
return JWK::parseKey($this->keySet[$keyId], $this->defaultAlg);
105107
}
106108

107109
/**
@@ -130,15 +132,43 @@ public function offsetUnset($offset): void
130132
throw new LogicException('Method not implemented');
131133
}
132134

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+
133159
private function keyIdExists(string $keyId): bool
134160
{
135161
if (null === $this->keySet) {
136162
$item = $this->getCacheItem();
137163
// Try to load keys from cache
138164
if ($item->isHit()) {
139-
// item found! Return it
140-
$jwks = $item->get();
141-
$this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg);
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+
}
142172
}
143173
}
144174

@@ -148,15 +178,14 @@ private function keyIdExists(string $keyId): bool
148178
}
149179
$request = $this->httpFactory->createRequest('GET', $this->jwksUri);
150180
$jwksResponse = $this->httpClient->sendRequest($request);
151-
$jwks = (string) $jwksResponse->getBody();
152-
$this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg);
181+
$this->keySet = $this->formatJwksForCache((string) $jwksResponse->getBody());
153182

154183
if (!isset($this->keySet[$keyId])) {
155184
return false;
156185
}
157186

158187
$item = $this->getCacheItem();
159-
$item->set($jwks);
188+
$item->set($this->keySet);
160189
if ($this->expiresAfter) {
161190
$item->expiresAfter($this->expiresAfter);
162191
}

Diff for: tests/CachedKeySetTest.php

+72-5
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ class CachedKeySetTest extends TestCase
1717
private $testJwksUri = 'https://jwk.uri';
1818
private $testJwksUriKey = 'jwkshttpsjwk.uri';
1919
private $testJwks1 = '{"keys": [{"kid":"foo","kty":"RSA","alg":"foo","n":"","e":""}]}';
20+
private $testCachedJwks1 = ['foo' => ['kid' => 'foo', 'kty' => 'RSA', 'alg' => 'foo', 'n' => '', 'e' => '']];
2021
private $testJwks2 = '{"keys": [{"kid":"bar","kty":"RSA","alg":"bar","n":"","e":""}]}';
2122
private $testJwks3 = '{"keys": [{"kid":"baz","kty":"RSA","n":"","e":""}]}';
2223

2324
private $googleRsaUri = 'https://www.googleapis.com/oauth2/v3/certs';
24-
// private $googleEcUri = 'https://www.gstatic.com/iap/verify/public_key-jwk';
25+
private $googleEcUri = 'https://www.gstatic.com/iap/verify/public_key-jwk';
2526

2627
public function testEmptyUriThrowsException()
2728
{
@@ -117,7 +118,7 @@ public function testKeyIdIsCached()
117118
$cacheItem->isHit()
118119
->willReturn(true);
119120
$cacheItem->get()
120-
->willReturn($this->testJwks1);
121+
->willReturn($this->testCachedJwks1);
121122

122123
$cache = $this->prophesize(CacheItemPoolInterface::class);
123124
$cache->getItem($this->testJwksUriKey)
@@ -136,6 +137,66 @@ public function testKeyIdIsCached()
136137
}
137138

138139
public function testCachedKeyIdRefresh()
140+
{
141+
$cacheItem = $this->prophesize(CacheItemInterface::class);
142+
$cacheItem->isHit()
143+
->shouldBeCalledOnce()
144+
->willReturn(true);
145+
$cacheItem->get()
146+
->shouldBeCalledOnce()
147+
->willReturn($this->testCachedJwks1);
148+
$cacheItem->set(Argument::any())
149+
->shouldBeCalledOnce()
150+
->will(function () {
151+
return $this;
152+
});
153+
154+
$cache = $this->prophesize(CacheItemPoolInterface::class);
155+
$cache->getItem($this->testJwksUriKey)
156+
->shouldBeCalledOnce()
157+
->willReturn($cacheItem->reveal());
158+
$cache->save(Argument::any())
159+
->shouldBeCalledOnce()
160+
->willReturn(true);
161+
162+
$cachedKeySet = new CachedKeySet(
163+
$this->testJwksUri,
164+
$this->getMockHttpClient($this->testJwks2), // updated JWK
165+
$this->getMockHttpFactory(),
166+
$cache->reveal()
167+
);
168+
$this->assertInstanceOf(Key::class, $cachedKeySet['foo']);
169+
$this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm());
170+
171+
$this->assertInstanceOf(Key::class, $cachedKeySet['bar']);
172+
$this->assertSame('bar', $cachedKeySet['bar']->getAlgorithm());
173+
}
174+
175+
public function testKeyIdIsCachedFromPreviousFormat()
176+
{
177+
$cacheItem = $this->prophesize(CacheItemInterface::class);
178+
$cacheItem->isHit()
179+
->willReturn(true);
180+
$cacheItem->get()
181+
->willReturn($this->testJwks1);
182+
183+
$cache = $this->prophesize(CacheItemPoolInterface::class);
184+
$cache->getItem($this->testJwksUriKey)
185+
->willReturn($cacheItem->reveal());
186+
$cache->save(Argument::any())
187+
->willReturn(true);
188+
189+
$cachedKeySet = new CachedKeySet(
190+
$this->testJwksUri,
191+
$this->prophesize(ClientInterface::class)->reveal(),
192+
$this->prophesize(RequestFactoryInterface::class)->reveal(),
193+
$cache->reveal()
194+
);
195+
$this->assertInstanceOf(Key::class, $cachedKeySet['foo']);
196+
$this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm());
197+
}
198+
199+
public function testCachedKeyIdRefreshFromPreviousFormat()
139200
{
140201
$cacheItem = $this->prophesize(CacheItemInterface::class);
141202
$cacheItem->isHit()
@@ -213,12 +274,18 @@ public function testJwtVerify()
213274
$payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')];
214275
$msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1');
215276

277+
// format the cached value to match the expected format
278+
$cachedJwks = [];
279+
$rsaKeySet = file_get_contents(__DIR__ . '/data/rsa-jwkset.json');
280+
foreach (json_decode($rsaKeySet, true)['keys'] as $k => $v) {
281+
$cachedJwks[$v['kid']] = $v;
282+
}
283+
216284
$cacheItem = $this->prophesize(CacheItemInterface::class);
217285
$cacheItem->isHit()
218286
->willReturn(true);
219287
$cacheItem->get()
220-
->willReturn(file_get_contents(__DIR__ . '/data/rsa-jwkset.json')
221-
);
288+
->willReturn($cachedJwks);
222289

223290
$cache = $this->prophesize(CacheItemPoolInterface::class);
224291
$cache->getItem($this->testJwksUriKey)
@@ -297,7 +364,7 @@ public function provideFullIntegration()
297364
{
298365
return [
299366
[$this->googleRsaUri],
300-
// [$this->googleEcUri, 'LYyP2g']
367+
[$this->googleEcUri, 'LYyP2g']
301368
];
302369
}
303370

0 commit comments

Comments
 (0)