update packages

This commit is contained in:
2026-05-28 18:44:36 +03:30
parent 9dbdfa7e21
commit 9b7405e64a
45 changed files with 2531 additions and 136 deletions
+649
View File
@@ -0,0 +1,649 @@
<?php
namespace Firebase\JWT;
use LogicException;
use OutOfBoundsException;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use RuntimeException;
class CachedKeySetTest extends TestCase
{
use ProphecyTrait;
private $testJwksUri = 'https://jwk.uri';
private $testJwksUriKey = 'jwkshttpsjwk.uri';
private $testJwks1 = '{"keys": [{"kid":"foo","kty":"RSA","alg":"foo","n":"","e":""}]}';
private $testCachedJwks1 = ['foo' => ['kid' => 'foo', 'kty' => 'RSA', 'alg' => 'foo', 'n' => '', 'e' => '']];
private $testJwks2 = '{"keys": [{"kid":"bar","kty":"RSA","alg":"bar","n":"","e":""}]}';
private $testJwks3 = '{"keys": [{"kid":"baz","kty":"RSA","n":"","e":""}]}';
private $googleRsaUri = 'https://www.googleapis.com/oauth2/v3/certs';
private $googleEcUri = 'https://www.gstatic.com/iap/verify/public_key-jwk';
public function testEmptyUriThrowsException()
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('JWKS URI is empty');
$cachedKeySet = new CachedKeySet(
'',
$this->prophesize(ClientInterface::class)->reveal(),
$this->prophesize(RequestFactoryInterface::class)->reveal(),
$this->prophesize(CacheItemPoolInterface::class)->reveal()
);
$cachedKeySet['foo'];
}
public function testOffsetSetThrowsException()
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('Method not implemented');
$cachedKeySet = new CachedKeySet(
$this->testJwksUri,
$this->prophesize(ClientInterface::class)->reveal(),
$this->prophesize(RequestFactoryInterface::class)->reveal(),
$this->prophesize(CacheItemPoolInterface::class)->reveal()
);
$cachedKeySet['foo'] = 'bar';
}
public function testOffsetUnsetThrowsException()
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('Method not implemented');
$cachedKeySet = new CachedKeySet(
$this->testJwksUri,
$this->prophesize(ClientInterface::class)->reveal(),
$this->prophesize(RequestFactoryInterface::class)->reveal(),
$this->prophesize(CacheItemPoolInterface::class)->reveal()
);
unset($cachedKeySet['foo']);
}
public function testOutOfBoundsThrowsException()
{
$this->expectException(OutOfBoundsException::class);
$this->expectExceptionMessage('Key ID not found');
$cachedKeySet = new CachedKeySet(
$this->testJwksUri,
$this->getMockHttpClient($this->testJwks1),
$this->getMockHttpFactory(),
$this->getMockEmptyCache()
);
// keyID doesn't exist
$cachedKeySet['bar'];
}
public function testInvalidHttpResponseThrowsException()
{
$this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('HTTP Error: 404 URL not found');
$this->expectExceptionCode(404);
$response = $this->prophesize('Psr\Http\Message\ResponseInterface');
$response->getStatusCode()
->shouldBeCalled()
->willReturn(404);
$response->getReasonPhrase()
->shouldBeCalledTimes(1)
->willReturn('URL not found');
$http = $this->prophesize(ClientInterface::class);
$http->sendRequest(Argument::any())
->shouldBeCalledTimes(1)
->willReturn($response->reveal());
$cachedKeySet = new CachedKeySet(
$this->testJwksUri,
$http->reveal(),
$this->getMockHttpFactory(),
$this->getMockEmptyCache()
);
isset($cachedKeySet[0]);
}
public function testWithExistingKeyId()
{
$cachedKeySet = new CachedKeySet(
$this->testJwksUri,
$this->getMockHttpClient($this->testJwks1),
$this->getMockHttpFactory(),
$this->getMockEmptyCache()
);
$this->assertInstanceOf(Key::class, $cachedKeySet['foo']);
$this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm());
}
public function testWithDefaultAlg()
{
$cachedKeySet = new CachedKeySet(
$this->testJwksUri,
$this->getMockHttpClient($this->testJwks3),
$this->getMockHttpFactory(),
$this->getMockEmptyCache(),
null,
false,
'baz256'
);
$this->assertInstanceOf(Key::class, $cachedKeySet['baz']);
$this->assertSame('baz256', $cachedKeySet['baz']->getAlgorithm());
}
public function testKeyIdIsCached()
{
$cacheItem = $this->prophesize(CacheItemInterface::class);
$cacheItem->isHit()
->willReturn(true);
$cacheItem->get()
->willReturn($this->testCachedJwks1);
$cache = $this->prophesize(CacheItemPoolInterface::class);
$cache->getItem($this->testJwksUriKey)
->willReturn($cacheItem->reveal());
$cache->save(Argument::any())
->willReturn(true);
$cachedKeySet = new CachedKeySet(
$this->testJwksUri,
$this->prophesize(ClientInterface::class)->reveal(),
$this->prophesize(RequestFactoryInterface::class)->reveal(),
$cache->reveal()
);
$this->assertInstanceOf(Key::class, $cachedKeySet['foo']);
$this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm());
}
public function testCachedKeyIdRefresh()
{
$cacheItem = $this->prophesize(CacheItemInterface::class);
$cacheItem->isHit()
->shouldBeCalledOnce()
->willReturn(true);
$cacheItem->get()
->shouldBeCalledOnce()
->willReturn($this->testCachedJwks1);
$cacheItem->set(Argument::any())
->shouldBeCalledOnce()
->will(function () {
return $this;
});
$cache = $this->prophesize(CacheItemPoolInterface::class);
$cache->getItem($this->testJwksUriKey)
->shouldBeCalledOnce()
->willReturn($cacheItem->reveal());
$cache->save(Argument::any())
->shouldBeCalledOnce()
->willReturn(true);
$cachedKeySet = new CachedKeySet(
$this->testJwksUri,
$this->getMockHttpClient($this->testJwks2), // updated JWK
$this->getMockHttpFactory(),
$cache->reveal()
);
$this->assertInstanceOf(Key::class, $cachedKeySet['foo']);
$this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm());
$this->assertInstanceOf(Key::class, $cachedKeySet['bar']);
$this->assertSame('bar', $cachedKeySet['bar']->getAlgorithm());
}
public function testKeyIdIsCachedFromPreviousFormat()
{
$cacheItem = $this->prophesize(CacheItemInterface::class);
$cacheItem->isHit()
->willReturn(true);
$cacheItem->get()
->willReturn($this->testJwks1);
$cache = $this->prophesize(CacheItemPoolInterface::class);
$cache->getItem($this->testJwksUriKey)
->willReturn($cacheItem->reveal());
$cache->save(Argument::any())
->willReturn(true);
$cachedKeySet = new CachedKeySet(
$this->testJwksUri,
$this->prophesize(ClientInterface::class)->reveal(),
$this->prophesize(RequestFactoryInterface::class)->reveal(),
$cache->reveal()
);
$this->assertInstanceOf(Key::class, $cachedKeySet['foo']);
$this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm());
}
public function testCachedKeyIdRefreshFromPreviousFormat()
{
$cacheItem = $this->prophesize(CacheItemInterface::class);
$cacheItem->isHit()
->shouldBeCalledOnce()
->willReturn(true);
$cacheItem->get()
->shouldBeCalledOnce()
->willReturn($this->testJwks1);
$cacheItem->set(Argument::any())
->shouldBeCalledOnce()
->will(function () {
return $this;
});
$cache = $this->prophesize(CacheItemPoolInterface::class);
$cache->getItem($this->testJwksUriKey)
->shouldBeCalledOnce()
->willReturn($cacheItem->reveal());
$cache->save(Argument::any())
->shouldBeCalledOnce()
->willReturn(true);
$cachedKeySet = new CachedKeySet(
$this->testJwksUri,
$this->getMockHttpClient($this->testJwks2), // updated JWK
$this->getMockHttpFactory(),
$cache->reveal()
);
$this->assertInstanceOf(Key::class, $cachedKeySet['foo']);
$this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm());
$this->assertInstanceOf(Key::class, $cachedKeySet['bar']);
$this->assertSame('bar', $cachedKeySet['bar']->getAlgorithm());
}
public function testCacheItemWithExpiresAfter()
{
$expiresAfter = 10;
$cacheItem = $this->prophesize(CacheItemInterface::class);
$cacheItem->isHit()
->shouldBeCalledOnce()
->willReturn(false);
$cacheItem->set(Argument::any())
->shouldBeCalledOnce()
->will(function () {
return $this;
});
$cacheItem->expiresAfter($expiresAfter)
->shouldBeCalledOnce()
->will(function () {
return $this;
});
$cache = $this->prophesize(CacheItemPoolInterface::class);
$cache->getItem($this->testJwksUriKey)
->shouldBeCalledOnce()
->willReturn($cacheItem->reveal());
$cache->save(Argument::any())
->shouldBeCalledOnce();
$cachedKeySet = new CachedKeySet(
$this->testJwksUri,
$this->getMockHttpClient($this->testJwks1),
$this->getMockHttpFactory(),
$cache->reveal(),
$expiresAfter
);
$this->assertInstanceOf(Key::class, $cachedKeySet['foo']);
$this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm());
}
public function testJwtVerify()
{
$privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem');
$payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')];
$msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1');
// format the cached value to match the expected format
$cachedJwks = [];
$rsaKeySet = file_get_contents(__DIR__ . '/data/rsa-jwkset.json');
foreach (json_decode($rsaKeySet, true)['keys'] as $k => $v) {
$cachedJwks[$v['kid']] = $v;
}
$cacheItem = $this->prophesize(CacheItemInterface::class);
$cacheItem->isHit()
->willReturn(true);
$cacheItem->get()
->willReturn($cachedJwks);
$cache = $this->prophesize(CacheItemPoolInterface::class);
$cache->getItem($this->testJwksUriKey)
->willReturn($cacheItem->reveal());
$cachedKeySet = new CachedKeySet(
$this->testJwksUri,
$this->prophesize(ClientInterface::class)->reveal(),
$this->prophesize(RequestFactoryInterface::class)->reveal(),
$cache->reveal()
);
$result = JWT::decode($msg, $cachedKeySet);
$this->assertSame('foo', $result->sub);
}
public function testRateLimit()
{
// We request the key 11 times, HTTP should only be called 10 times
$shouldBeCalledTimes = 10;
// Instantiate the cached key set
$cachedKeySet = new CachedKeySet(
$this->testJwksUri,
$this->getMockHttpClient($this->testJwks1, $shouldBeCalledTimes),
$this->getMockHttpFactory($shouldBeCalledTimes),
new TestMemoryCacheItemPool(),
10, // expires after seconds
true // enable rate limiting
);
$invalidKid = 'invalidkey';
for ($i = 0; $i < 10; $i++) {
$this->assertFalse(isset($cachedKeySet[$invalidKid]));
}
// The 11th time does not call HTTP
$this->assertFalse(isset($cachedKeySet[$invalidKid]));
}
public function testRateLimitWithExpiresAfter()
{
// We request the key 17 times, HTTP should only be called 15 times
$shouldBeCalledTimes = 10;
$cachedTimes = 2;
$afterExpirationTimes = 5;
$totalHttpTimes = $shouldBeCalledTimes + $afterExpirationTimes;
$cachePool = new TestMemoryCacheItemPool();
// Instantiate the cached key set
$cachedKeySet = new CachedKeySet(
$this->testJwksUri,
$this->getMockHttpClient($this->testJwks1, $totalHttpTimes),
$this->getMockHttpFactory($totalHttpTimes),
$cachePool,
10, // expires after seconds
true // enable rate limiting
);
// Set the rate limit cache to expire after 1 second
$cacheItem = $cachePool->getItem('jwksratelimitjwkshttpsjwk.uri');
$cacheItem->set([
'expiry' => new \DateTime('+1 second', new \DateTimeZone('UTC')),
'callsPerMinute' => 0,
]);
$cacheItem->expiresAfter(1);
$cachePool->save($cacheItem);
$invalidKid = 'invalidkey';
for ($i = 0; $i < $shouldBeCalledTimes; $i++) {
$this->assertFalse(isset($cachedKeySet[$invalidKid]));
}
// The next calls do not call HTTP
for ($i = 0; $i < $cachedTimes; $i++) {
$this->assertFalse(isset($cachedKeySet[$invalidKid]));
}
sleep(1); // wait for cache to expire
// These calls DO call HTTP because the cache has expired
for ($i = 0; $i < $afterExpirationTimes; $i++) {
$this->assertFalse(isset($cachedKeySet[$invalidKid]));
}
}
/**
* @dataProvider provideFullIntegration
*/
public function testFullIntegration(string $jwkUri): void
{
if (!class_exists(\GuzzleHttp\Psr7\HttpFactory::class)) {
self::markTestSkipped('Guzzle 7 only');
}
// Create cache and http objects
$cache = new TestMemoryCacheItemPool();
$http = new \GuzzleHttp\Client();
$factory = new \GuzzleHttp\Psr7\HttpFactory();
// Determine "kid" dynamically, because these constantly change
$response = $http->get($jwkUri);
$json = (string) $response->getBody();
$keys = json_decode($json, true);
$kid = $keys['keys'][0]['kid'] ?? null;
$this->assertNotNull($kid);
// Instantiate the cached key set
$cachedKeySet = new CachedKeySet(
$jwkUri,
$http,
$factory,
$cache
);
$this->assertArrayHasKey($kid, $cachedKeySet);
$key = $cachedKeySet[$kid];
$this->assertInstanceOf(Key::class, $key);
$this->assertSame($keys['keys'][0]['alg'], $key->getAlgorithm());
}
public function provideFullIntegration()
{
return [
[$this->googleRsaUri],
[$this->googleEcUri, 'LYyP2g']
];
}
private function getMockHttpClient($testJwks, int $timesCalled = 1)
{
$body = $this->prophesize('Psr\Http\Message\StreamInterface');
$body->__toString()
->shouldBeCalledTimes($timesCalled)
->willReturn($testJwks);
$response = $this->prophesize('Psr\Http\Message\ResponseInterface');
$response->getBody()
->shouldBeCalledTimes($timesCalled)
->willReturn($body->reveal());
$response->getStatusCode()
->shouldBeCalledTimes($timesCalled)
->willReturn(200);
$http = $this->prophesize(ClientInterface::class);
$http->sendRequest(Argument::any())
->shouldBeCalledTimes($timesCalled)
->willReturn($response->reveal());
return $http->reveal();
}
private function getMockHttpFactory(int $timesCalled = 1)
{
$request = $this->prophesize('Psr\Http\Message\RequestInterface');
$factory = $this->prophesize(RequestFactoryInterface::class);
$factory->createRequest('GET', $this->testJwksUri)
->shouldBeCalledTimes($timesCalled)
->willReturn($request->reveal());
return $factory->reveal();
}
private function getMockEmptyCache()
{
$cacheItem = $this->prophesize(CacheItemInterface::class);
$cacheItem->isHit()
->shouldBeCalledOnce()
->willReturn(false);
$cacheItem->set(Argument::any())
->will(function () {
return $this;
});
$cache = $this->prophesize(CacheItemPoolInterface::class);
$cache->getItem($this->testJwksUriKey)
->shouldBeCalledOnce()
->willReturn($cacheItem->reveal());
$cache->save(Argument::any())
->willReturn(true);
return $cache->reveal();
}
}
/**
* A cache item pool
*/
final class TestMemoryCacheItemPool implements CacheItemPoolInterface
{
private $items;
private $deferredItems;
public function getItem($key): CacheItemInterface
{
$item = current($this->getItems([$key]));
$item->expiresAt(null); // mimic symfony cache behavior
return $item;
}
public function getItems(array $keys = []): iterable
{
$items = [];
foreach ($keys as $key) {
$items[$key] = $this->hasItem($key) ? clone $this->items[$key] : new TestMemoryCacheItem($key);
}
return $items;
}
public function hasItem($key): bool
{
return isset($this->items[$key]) && $this->items[$key]->isHit();
}
public function clear(): bool
{
$this->items = [];
$this->deferredItems = [];
return true;
}
public function deleteItem($key): bool
{
return $this->deleteItems([$key]);
}
public function deleteItems(array $keys): bool
{
foreach ($keys as $key) {
unset($this->items[$key]);
}
return true;
}
public function save(CacheItemInterface $item): bool
{
$this->items[$item->getKey()] = $item;
return true;
}
public function saveDeferred(CacheItemInterface $item): bool
{
$this->deferredItems[$item->getKey()] = $item;
return true;
}
public function commit(): bool
{
foreach ($this->deferredItems as $item) {
$this->save($item);
}
$this->deferredItems = [];
return true;
}
}
/**
* A cache item.
*/
final class TestMemoryCacheItem implements CacheItemInterface
{
private $key;
private $value;
private $expiration;
private $isHit = false;
public function __construct(string $key)
{
$this->key = $key;
}
public function getKey(): string
{
return $this->key;
}
public function get(): mixed
{
return $this->isHit() ? $this->value : null;
}
public function isHit(): bool
{
if (!$this->isHit) {
return false;
}
if ($this->expiration === null) {
return true;
}
return $this->currentTime()->getTimestamp() < $this->expiration->getTimestamp();
}
public function set(mixed $value): static
{
$this->isHit = true;
$this->value = $value;
return $this;
}
public function expiresAt($expiration): static
{
$this->expiration = $expiration;
return $this;
}
public function expiresAfter($time): static
{
$this->expiration = $this->currentTime()->add(new \DateInterval("PT{$time}S"));
return $this;
}
protected function currentTime()
{
return new \DateTime('now', new \DateTimeZone('UTC'));
}
}