Skip to content

feat: add cached keyset #397

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 47 commits into from
Apr 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
63ae30a
WIP: add CachedKeySet
bshaffer Feb 8, 2022
6fa256e
add tests
bshaffer Feb 8, 2022
66881d7
fix cs
bshaffer Feb 8, 2022
ff7339f
remove require-dev deps
bshaffer Feb 9, 2022
dc6e704
add integration test and integration test config
bshaffer Feb 9, 2022
9051308
add more tests
bshaffer Feb 9, 2022
e366659
use psr/cache 2.0.0
bshaffer Feb 9, 2022
56bb69e
Merge branch 'main' into add-cached-keyset
bshaffer Feb 16, 2022
006cac9
Update bootstrap-system.php
bshaffer Feb 16, 2022
82688db
Update CachedKeySetTest.php
bshaffer Feb 16, 2022
19e8699
Merge branch 'main' into add-cached-keyset
bshaffer Feb 16, 2022
f9d72a4
remove special tests, update for PHP 8
bshaffer Feb 16, 2022
6df0fbc
style fix
bshaffer Feb 16, 2022
351f0cf
Merge branch 'main' into add-cached-keyset
bshaffer Feb 16, 2022
a0f8008
Update README.md
bshaffer Feb 16, 2022
83ac363
Merge branch 'main' into add-cached-keyset
bshaffer Mar 20, 2022
e5f9a53
fix phpstan
bshaffer Mar 20, 2022
5a98f72
fix tests
bshaffer Mar 23, 2022
7341c6d
downgrade psr cache for compatibility (force-push to trigger actions)
bshaffer Mar 23, 2022
58ff40e
Merge branch 'main' into add-cached-keyset
bshaffer Mar 23, 2022
bcccc51
Update composer.json
bshaffer Mar 23, 2022
679746c
maybe fix dependency hell
bshaffer Mar 23, 2022
3060488
add guzzle 6 for PHP 7.1
bshaffer Mar 23, 2022
7595b5c
fix php7.1 tests
bshaffer Mar 24, 2022
320a140
rearange deps
bshaffer Mar 24, 2022
7f20b2d
Update README.md
bshaffer Apr 13, 2022
341a415
Update src/CachedKeySet.php
bshaffer Apr 13, 2022
663bfd0
Update src/CachedKeySet.php
bshaffer Apr 13, 2022
617e67a
add rate limit
bshaffer Apr 15, 2022
acdc97a
remove mixed keyword for < PHP 8.0
bshaffer Apr 16, 2022
4e9cebb
Merge branch 'main' into add-cached-keyset
bshaffer Apr 16, 2022
967c5cb
change jwk to jwks to be mmore correct
bshaffer Apr 16, 2022
27aa10a
Merge branch 'add-cached-keyset' of github.com:firebase/php-jwt into …
bshaffer Apr 16, 2022
182a136
update README with ratelimit info
bshaffer Apr 18, 2022
98adbe4
Update README.md
bshaffer Apr 19, 2022
995b530
Merge branch 'main' into add-cached-keyset
bshaffer Apr 21, 2022
2af9d51
Update README.md
bshaffer Apr 25, 2022
7ba9d70
Update README.md
bshaffer Apr 25, 2022
b657333
Update README.md
bshaffer Apr 25, 2022
1728e58
Merge branch 'main' into add-cached-keyset
bshaffer Apr 25, 2022
dd33453
Update src/CachedKeySet.php
bshaffer Apr 25, 2022
2d22686
address more review comments
bshaffer Apr 25, 2022
d3a41ad
Merge branch 'add-cached-keyset' of github.com:firebase/php-jwt into …
bshaffer Apr 25, 2022
04b46c1
fix phpstan implements
bshaffer Apr 25, 2022
e3f21d5
add example for cached key set example
bshaffer Apr 25, 2022
e7b31c6
Merge branch 'main' into add-cached-keyset
bshaffer Apr 27, 2022
8aa0a5b
cs fixer and phpstan fixes
bshaffer Apr 27, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,6 @@ jobs:
php-version: '8.0'
- name: Run Script
run: |
composer install
composer global require phpstan/phpstan
~/.composer/vendor/bin/phpstan analyse
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,45 @@ $jwks = ['keys' => []];
JWT::decode($payload, JWK::parseKeySet($jwks));
```

Using Cached Key Sets
---------------------

The `CachedKeySet` class can be used to fetch and cache JWKS (JSON Web Key Sets) from a public URI.
This has the following advantages:

1. The results are cached for performance.
2. If an unrecognized key is requested, the cache is refreshed, to accomodate for key rotation.
3. If rate limiting is enabled, the JWKS URI will not make more than 10 requests a second.

```php
use Firebase\JWT\CachedKeySet;
use Firebase\JWT\JWT;

// The URI for the JWKS you wish to cache the results from
$jwksUri = 'https://www.gstatic.com/iap/verify/public_key-jwk';

// Create an HTTP client (can be any PSR-7 compatible HTTP client)
$httpClient = new GuzzleHttp\Client();

// Create an HTTP request factory (can be any PSR-17 compatible HTTP request factory)
$httpFactory = new GuzzleHttp\Psr\HttpFactory();

// Create a cache item pool (can be any PSR-6 compatible cache item pool)
$cacheItemPool = Phpfastcache\CacheManager::getInstance('files');

$keySet = new CachedKeySet(
$jwksUri,
$httpClient,
$httpFactory,
$cacheItemPool,
null, // $expiresAfter int seconds to set the JWKS to expire
true // $rateLimit true to enable rate limit of 10 RPS on lookup of invalid keys
);

$jwt = 'eyJhbGci...'; // Some JWT signed by a key from the $jwkUri above
$decoded = JWT::decode($jwt, $keySet);
```

Miscellaneous
-------------

Expand Down
7 changes: 6 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
}
},
"require-dev": {
"phpunit/phpunit": "^7.5||9.5"
"guzzlehttp/guzzle": "^6.5||^7.4",
"phpspec/prophecy-phpunit": "^1.1",
"phpunit/phpunit": "^7.5||^9.5",
"psr/cache": "^1.0||^2.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
}
}
2 changes: 1 addition & 1 deletion phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
bootstrap="tests/bootstrap.php"
bootstrap="vendor/autoload.php"
>
<testsuites>
<testsuite name="PHP JSON Web Token Test Suite">
Expand Down
225 changes: 225 additions & 0 deletions src/CachedKeySet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
<?php

namespace Firebase\JWT;

use ArrayAccess;
use LogicException;
use OutOfBoundsException;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use RuntimeException;

/**
* @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>
*/
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;

public function __construct(
string $jwksUri,
ClientInterface $httpClient,
RequestFactoryInterface $httpFactory,
CacheItemPoolInterface $cache,
int $expiresAfter = null,
bool $rateLimit = false
) {
$this->jwksUri = $jwksUri;
$this->httpClient = $httpClient;
$this->httpFactory = $httpFactory;
$this->cache = $cache;
$this->expiresAfter = $expiresAfter;
$this->rateLimit = $rateLimit;
$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];
}

/**
* @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
{
$keySetToCache = null;
if (null === $this->keySet) {
$item = $this->getCacheItem();
// Try to load keys from cache
if ($item->isHit()) {
// item found! Return it
$this->keySet = $item->get();
}
}

if (!isset($this->keySet[$keyId])) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this code is currently not proof against ddos attacks via key spamming, or is it?

this scenario was described here: https://github.com/auth0/node-jwks-rsa#rate-limiting

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it does not. We can add this to the cache without too much issue, however. I'll take a look. Thanks for pointing this out.

if ($this->rateLimitExceeded()) {
return false;
}
$request = $this->httpFactory->createRequest('get', $this->jwksUri);
$jwksResponse = $this->httpClient->sendRequest($request);
$jwks = json_decode((string) $jwksResponse->getBody(), true);
$this->keySet = $keySetToCache = JWK::parseKeySet($jwks);

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

if ($keySetToCache) {
$item = $this->getCacheItem();
$item->set($keySetToCache);
if ($this->expiresAfter) {
$item->expiresAfter($this->expiresAfter);
}
$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;
}
}
}
19 changes: 8 additions & 11 deletions src/JWT.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

namespace Firebase\JWT;

use ArrayAccess;
use DateTime;
use DomainException;
use Exception;
use InvalidArgumentException;
use OpenSSLAsymmetricKey;
use OpenSSLCertificate;
use stdClass;
use TypeError;
use UnexpectedValueException;

/**
Expand Down Expand Up @@ -68,7 +68,7 @@ class JWT
* Decodes a JWT string into a PHP object.
*
* @param string $jwt The JWT
* @param Key|array<string, Key> $keyOrKeyArray The Key or associative array of key IDs (kid) to Key objects.
* @param Key|array<string,Key> $keyOrKeyArray The Key or associative array of key IDs (kid) to Key objects.
* If the algorithm used is asymmetric, this is the public key
* Each Key object contains an algorithm and matching key.
* Supported algorithms are 'ES384','ES256', 'HS256', 'HS384',
Expand Down Expand Up @@ -409,7 +409,7 @@ public static function urlsafeB64Encode(string $input): string
/**
* Determine if an algorithm has been provided for each Key
*
* @param Key|array<string, Key> $keyOrKeyArray
* @param Key|ArrayAccess<string,Key>|array<string,Key> $keyOrKeyArray
* @param string|null $kid
*
* @throws UnexpectedValueException
Expand All @@ -424,15 +424,12 @@ private static function getKey(
return $keyOrKeyArray;
}

foreach ($keyOrKeyArray as $keyId => $key) {
if (!$key instanceof Key) {
throw new TypeError(
'$keyOrKeyArray must be an instance of Firebase\JWT\Key key or an '
. 'array of Firebase\JWT\Key keys'
);
}
if ($keyOrKeyArray instanceof CachedKeySet) {
// Skip "isset" check, as this will automatically refresh if not set
return $keyOrKeyArray[$kid];
}
if (!isset($kid)) {

if (empty($kid)) {
throw new UnexpectedValueException('"kid" empty, unable to lookup correct key');
}
if (!isset($keyOrKeyArray[$kid])) {
Expand Down
Loading