firebase / php-jwt

PHP package for JWT

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

parseKeySet consuming a lot of time

joskfg opened this issue · comments

commented

Hello,

I have a case where I have a lot of clients (kids) that access my application. Currently my implementation is like

return JWT::decode($tokenString, $this->getJwks($useCache));

I didn't notice nothing until I've got a high volume of kids. The implementation from getJwks uses internally JWK::parseKeySet($jwks);. And this method is doing this loop

foreach ($jwks['keys'] as $k => $v) {
            $kid = isset($v['kid']) ? $v['kid'] : $k;
            if ($key = self::parseKey($v, $defaultAlg)) {
                $keys[(string) $kid] = $key;
            }
        }

This means that in my case, with 204 kids, for every request, when the token is validated, it parse the keys and it takes 191ms, that are 204 calls to openssl_pkey_get_public.

I think that it could be possible to just get the kid from the JWT header and generate the Key object for a single jwk instead of parse all of them.

Am I missing something? Is this approach right?

Tahnks

Hey @joskfg !

For starters, you could definitely be caching the result of getJwks so that this does not happen with every request.

Secondly, we've already implemented a CachedKeySet, but this only caches the request of the keyset. I know we had a good reason for not caching the parsed data (but can't remember what it was). We could look into providing an out-of-the-box way to do this, as you're right, calling openssl multiple times on every request is not a great solution.

For starters I'd definitely try adding caching to the result of JWK::parseKeySet in getJwks, and see how that goes.

commented

We tried to cache the output, but PHP throws a Throwable becase the objects that are given by JWK::parseKeySet are not serializable. Our patch have been to parse the token, get the kid from headers, and send to JWK::parseKeySet just the key to be checked, but it is not clean because it is not integrated in the library.

Actually the library filter the keys by kid and check just one, but it is done in the decode step, so the JWK::parseKeySet parses all of them. I think that if that filter is done in an early step, you could avoid all that not necessary parsing. The library is parsing all keys to just get a specific key after it.

Current workflow:

  1. Parse all keys and transform them to objects, including OpenSSL objects that take a lot of time to be created (Maybe due to openssl/openssl#17064).
public static function parseKeySet(array $jwks, string $defaultAlg = null): array
    {
        ......
        foreach ($jwks['keys'] as $k => $v) {
            $kid = isset($v['kid']) ? $v['kid'] : $k;
            if ($key = self::parseKey($v, $defaultAlg)) {
                $keys[(string) $kid] = $key;
            }
        }
       ......
        return $keys;
    }
  1. Get just one of the parsed keys:
public static function decode(
        string $jwt,
        $keyOrKeyArray
    ): stdClass {
        .....
        $key = self::getKey($keyOrKeyArray, property_exists($header, 'kid') ? $header->kid : null);
        .....

        return $payload;
    }

Expanding self::getKey method:

private static function getKey(
        $keyOrKeyArray,
        ?string $kid
    ): Key {
       ......
        return $keyOrKeyArray[$kid];
    }

And the current Cache system doesn't solve the issue because it just cache the httpclient response and parse all keys:

private function keyIdExists(string $keyId): bool
    {
        if (null === $this->keySet) {
            $item = $this->getCacheItem();
            // Try to load keys from cache
            if ($item->isHit()) {
                // item found! Return it
                $jwks = $item->get();
                $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg);
            }
        }

        if (!isset($this->keySet[$keyId])) {
            . . . 
            $jwks = (string) $jwksResponse->getBody();
            $this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg);

            $item = $this->getCacheItem();
            $item->set($jwks);
            . . . 
            $this->cache->save($item);
        }

        return true;
    }

Our current approach:

    private function getValidatedToken(string $tokenString): object
    {
        $header = json_decode(JWT::urlsafeB64Decode(explode('.', $tokenString)[0]));
        .....
        return JWT::decode($tokenString, $this->getJwks($header->kid));
    }

    private function getJwks(string $kid): array
    {
        $jwks = $this->cache->get(self::CACHE_KEY);
        .....
        foreach($jwks['keys'] as $key) {
            if($key['kid'] === $kid) {
                return JWK::parseKeySet(['keys' => [$key]]);
            }
        }
        .....
    }

As you can see, it is not clean at all, because it forces us to use our own cache and work with library internals parsing the JWT or implement the token decoding on our own.

@joskfg thanks for your very thorough description of the problem! Check out my solution in #486 (you can even install it with composer require firebase/php-jwt:dev-improve-keyset-cachng) and see if this fixes your issue.

Essentially I changed the logic so that instead of running parseKey on the entire keyset every time, we only run it on the kid once it's found in the JWKS.

commented

@bshaffer Tested and it works smooth. Now it just parse the needed key.

The concern with this approach is that only works if you use a CachedKeySet object. If you don't want to use it, the behavior is still the same.

This works for me :-). Waiting for merge and release, to finish my changes.

Thanks a lot! Very clever solution.

Seems like no action is pending here since the PR is merged and released, so closing this issue.